Skip to content

Commit a9da5c0

Browse files
[Fixes #14309] Generalize remote service type registration and auth handling (#14328)
* [Fixes #14309] Generalize remote service type registration and auth handling * [Fixes #14309] fixes review suggestions * [Fixes #14309] update test case failure * [Fixes #14309] fixes review comment * [Fixes #14309] update review suggestions * [Fixes #14309] update redundant service fallback and related test * [Fixes #14309] usage of pre_processing instead of pre_validation * [Fixes #14309] Fixes review suggestions * [Fixes #14309] fixes review suggestion --------- Co-authored-by: Mattia Giupponi <mattia.giupponi@gmail.com>
1 parent 5ce2cab commit a9da5c0

22 files changed

Lines changed: 540 additions & 284 deletions

File tree

geonode/security/auth_handlers.py

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
# along with this program. If not, see <http://www.gnu.org/licenses/>.
1717
#
1818
#########################################################################
19+
import base64
1920
from abc import ABC, abstractmethod
2021

2122
from django.core.exceptions import ValidationError
@@ -39,6 +40,13 @@ def _init_from_config(self):
3940
def get_request_auth(self) -> AuthBase:
4041
raise NotImplementedError
4142

43+
def get_extra_config(self, **kwargs):
44+
"""
45+
Return optional runtime configuration for consumers that need auth-specific settings.
46+
example: gdal headers
47+
"""
48+
return {}
49+
4250
def auth_request(self, request, **kwargs):
4351
raise NotImplementedError
4452

@@ -50,8 +58,12 @@ def validate(cls, payload, instance=None):
5058
raise NotImplementedError
5159

5260
@classmethod
53-
def create_auth_config(cls, **kwargs):
54-
raise NotImplementedError
61+
def create_auth_config(cls, payload):
62+
cls.validate(payload)
63+
auth_config = AuthConfig(type=cls.handled_type)
64+
auth_config.payload = payload
65+
auth_config.save()
66+
return auth_config
5567

5668

5769
class HashableAuthBase(AuthBase):
@@ -90,17 +102,6 @@ def validate(cls, payload, instance=None):
90102
raise ValidationError("Password is required for basic authentication.")
91103
return payload
92104

93-
@classmethod
94-
def create_auth_config(cls, username, password):
95-
if username is None and password is None:
96-
return None
97-
payload = {"username": username, "password": password}
98-
cls.validate(payload)
99-
auth_config = AuthConfig(type=cls.handled_type)
100-
auth_config.payload = payload
101-
auth_config.save()
102-
return auth_config
103-
104105
def _init_from_config(self):
105106
payload = self.config.payload
106107
self.username = payload.get("username")
@@ -109,6 +110,12 @@ def _init_from_config(self):
109110
def get_request_auth(self) -> AuthBase:
110111
return HashableAuthBase(HTTPBasicAuth(self.username, self.password))
111112

113+
def get_extra_config(self, **kwargs):
114+
url = kwargs.get("url")
115+
credentials = f"{self.username}:{self.password}".encode()
116+
token = base64.b64encode(credentials).decode()
117+
return {"url": url, "gdal": {"GDAL_HTTP_HEADERS": f"Authorization: Basic {token}"}}
118+
112119
def auth_request(self, request, **kwargs):
113120
request.auth = self.get_request_auth()
114121
return request

geonode/security/tests.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4204,7 +4204,7 @@ def test_url_pattern_auth_config_string_representation(self):
42044204
self.assertEqual(str(url_pattern_auth_config), "https://example.com/*")
42054205

42064206
def test_basic_auth_payload_round_trip(self):
4207-
auth_config = BasicAuthHandler.create_auth_config("test_user", "test_password")
4207+
auth_config = BasicAuthHandler.create_auth_config({"username": "test_user", "password": "test_password"})
42084208

42094209
self.assertEqual(auth_config.type, "basic")
42104210
self.assertNotIn("test_user", auth_config._payload)
@@ -4248,6 +4248,13 @@ def test_basic_auth_handler_auth_request_sets_request_auth(self):
42484248
self.assertEqual(request.auth.auth.username, "test_user")
42494249
self.assertEqual(request.auth.auth.password, "test_password")
42504250

4251+
def test_basic_auth_handler_get_extra_config(self):
4252+
auth_handler = auth_handler_registry.build(self.auth_config)
4253+
expected_token = base64.b64encode(b"test_user:test_password").decode()
4254+
config = auth_handler.get_extra_config(url="https://example.com/data.tif")
4255+
self.assertEqual("https://example.com/data.tif", config["url"])
4256+
self.assertEqual({"GDAL_HTTP_HEADERS": f"Authorization: Basic {expected_token}"}, config["gdal"])
4257+
42514258

42524259
class AuthHandlerRegistryTests(TestCase):
42534260
class SampleAuthHandler(AuthHandler):

geonode/services/forms.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,8 @@
2929
from geonode.security.models import AuthConfig
3030

3131
from . import enumerations
32-
from .models import Service
32+
from .models import Service, get_service_type_choices
3333
from .serviceprocessors import get_service_handler
34-
from geonode.services.serviceprocessors import get_available_service_types
3534
from geonode.utils import is_safe_url
3635

3736
logger = logging.getLogger(__name__)
@@ -48,7 +47,7 @@ class CreateServiceForm(forms.Form):
4847
)
4948
type = forms.ChoiceField(
5049
label=_("Service Type"),
51-
choices=[(k, v["label"]) for k, v in get_available_service_types().items()], # from dictionary to tuple
50+
choices=get_service_type_choices,
5251
initial="AUTO",
5352
)
5453

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Generated by Django 5.2.15 on 2026-06-09 12:56
2+
3+
import geonode.services.models
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
("services", "0059_remove_service_password_remove_service_username"),
11+
]
12+
13+
operations = [
14+
migrations.AlterField(
15+
model_name="service",
16+
name="type",
17+
field=models.CharField(choices=geonode.services.models.get_service_type_choices, max_length=10),
18+
),
19+
]

geonode/services/models.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from django.db import models
2424
from django.conf import settings
2525

26+
from django.urls import reverse
2627
from django.utils.translation import gettext_lazy as _
2728

2829
from geonode.base.models import ResourceBase
@@ -32,15 +33,18 @@
3233
from geonode.services.serviceprocessors import get_available_service_types
3334
from . import enumerations
3435

35-
service_type_as_tuple = [(k, v["label"]) for k, v in get_available_service_types().items()]
36+
37+
def get_service_type_choices():
38+
return [(k, v["label"]) for k, v in get_available_service_types().items()]
39+
3640

3741
logger = logging.getLogger("geonode.services")
3842

3943

4044
class Service(ResourceBase):
4145
"""Service Class to represent remote Geo Web Services"""
4246

43-
type = models.CharField(max_length=10, choices=service_type_as_tuple)
47+
type = models.CharField(max_length=10, choices=get_service_type_choices)
4448
method = models.CharField(
4549
max_length=1,
4650
choices=(
@@ -102,15 +106,18 @@ def service_url(self):
102106
@property
103107
def ptype(self):
104108
# Return the gxp ptype that should be used to display layers
105-
return GXP_PTYPES[self.type] if self.type else None
109+
return GXP_PTYPES.get(self.type) if self.type else None
106110

107111
@property
108112
def service_type(self):
109113
# Return the gxp ptype that should be used to display layers
110-
return [x for x in service_type_as_tuple if x[0] == self.type][0][1]
114+
service_type = get_available_service_types().get(self.type)
115+
if service_type:
116+
return service_type["label"]
117+
return self.type
111118

112119
def get_absolute_url(self):
113-
return "/services/%i" % self.id
120+
return reverse("service_detail", kwargs={"service_id": self.id})
114121

115122
class Meta:
116123
# custom permissions,

geonode/services/serviceprocessors/__init__.py

Lines changed: 4 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -18,45 +18,17 @@
1818
#########################################################################
1919
import logging
2020

21-
from collections import OrderedDict
22-
from django.utils.translation import gettext_lazy as _
2321
from django.conf import settings
2422
from geonode.services import enumerations
25-
from geonode.services.utils import parse_services_types
2623
from django.core.cache import caches
24+
from geonode.services.serviceprocessors.registry import service_type_registry
2725

2826
service_cache = caches["services"]
2927
logger = logging.getLogger(__name__)
3028

3129

32-
def get_available_service_types():
33-
# LGTM: Fixes - Module uses member of cyclically imported module, which can lead to failure at import time.
34-
from geonode.services.serviceprocessors.wms import GeoNodeServiceHandler, WmsServiceHandler
35-
from geonode.services.serviceprocessors.arcgis import ArcImageServiceHandler, ArcMapServiceHandler
36-
37-
default = OrderedDict(
38-
{
39-
enumerations.WMS: {"OWS": True, "handler": WmsServiceHandler, "label": _("Web Map Service")},
40-
enumerations.GN_WMS: {
41-
"OWS": True,
42-
"handler": GeoNodeServiceHandler,
43-
"label": _("GeoNode (Web Map Service)"),
44-
},
45-
# enumerations.WFS: {"OWS": True, "handler": ServiceHandlerBase, "label": _('Paired WMS/WFS/WCS'},
46-
# enumerations.TMS: {"OWS": False, "handler": ServiceHandlerBase, "label": _('Paired WMS/WFS/WCS'},
47-
enumerations.REST_MAP: {"OWS": False, "handler": ArcMapServiceHandler, "label": _("ArcGIS REST MapServer")},
48-
enumerations.REST_IMG: {
49-
"OWS": False,
50-
"handler": ArcImageServiceHandler,
51-
"label": _("ArcGIS REST ImageServer"),
52-
},
53-
# enumerations.CSW: {"OWS": False, "handler": ServiceHandlerBase, "label": _('Catalogue Service')},
54-
# enumerations.OGP: {"OWS": True, "handler": ServiceHandlerBase, "label": _('OpenGeoPortal')}, # TODO: verify this
55-
# enumerations.HGL: {"OWS": False, "handler": ServiceHandlerBase, "label": _('Harvard Geospatial Library')}, # TODO: verify this
56-
}
57-
)
58-
59-
return OrderedDict({**default, **parse_services_types()})
30+
def get_available_service_types():
31+
return service_type_registry.get_available_service_types()
6032

6133

6234
def get_service_handler(base_url, service_type=enumerations.AUTO, service_id=None, *args, **kwargs):
@@ -67,9 +39,7 @@ def get_service_handler(base_url, service_type=enumerations.AUTO, service_id=Non
6739
if entry := service_cache.get(base_url):
6840
return entry
6941

70-
handlers = get_available_service_types()
71-
72-
handler = handlers.get(service_type, {}).get("handler")
42+
handler = service_type_registry.get_handler_class(service_type)
7343
try:
7444
service_handler = handler(base_url, service_id, *args, **kwargs)
7545
service_cache.set(service_handler.url, service_handler, settings.SERVICE_CACHE_EXPIRATION_TIME)
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
#########################################################################
2+
#
3+
# Copyright (C) 2026 OSGeo
4+
#
5+
# This program is free software: you can redistribute it and/or modify
6+
# it under the terms of the GNU General Public License as published by
7+
# the Free Software Foundation, either version 3 of the License, or
8+
# (at your option) any later version.
9+
#
10+
# This program is distributed in the hope that it will be useful,
11+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
# GNU General Public License for more details.
14+
#
15+
# You should have received a copy of the GNU General Public License
16+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
17+
#
18+
#########################################################################
19+
from collections import OrderedDict
20+
21+
from django.conf import settings
22+
from django.utils.module_loading import import_string
23+
from django.utils.translation import gettext_lazy as _
24+
25+
from geonode.services import enumerations
26+
27+
28+
class ServiceTypeRegistry:
29+
def __init__(self):
30+
self.registry = None
31+
32+
def register(self, service_type, handler, label, OWS=False, **kwargs):
33+
self.init_registry()
34+
self.registry[service_type] = {
35+
"OWS": OWS,
36+
"handler": handler,
37+
"label": label,
38+
**kwargs,
39+
}
40+
41+
def unregister(self, service_type):
42+
self.init_registry()
43+
self.registry.pop(service_type, None)
44+
45+
def init_registry(self):
46+
if self.registry is not None:
47+
return
48+
49+
self.registry = OrderedDict()
50+
self._register_default_service_types()
51+
self._register_configured_service_types()
52+
53+
def reset(self):
54+
self.registry = None
55+
56+
def get_available_service_types(self):
57+
self.init_registry()
58+
return OrderedDict(self.registry)
59+
60+
def get_handler_class(self, service_type):
61+
service_type_config = self.get_available_service_types().get(service_type, {})
62+
handler = service_type_config.get("handler")
63+
if isinstance(handler, str):
64+
return import_string(handler)
65+
return handler
66+
67+
def _register_default_service_types(self):
68+
# Keep imports lazy to avoid circular imports during Django app loading.
69+
from geonode.services.serviceprocessors.arcgis import ArcImageServiceHandler, ArcMapServiceHandler
70+
from geonode.services.serviceprocessors.wms import GeoNodeServiceHandler, WmsServiceHandler
71+
72+
self.register(enumerations.WMS, WmsServiceHandler, _("Web Map Service"), OWS=True)
73+
self.register(enumerations.GN_WMS, GeoNodeServiceHandler, _("GeoNode (Web Map Service)"), OWS=True)
74+
self.register(enumerations.REST_MAP, ArcMapServiceHandler, _("ArcGIS REST MapServer"))
75+
self.register(enumerations.REST_IMG, ArcImageServiceHandler, _("ArcGIS REST ImageServer"))
76+
77+
def _register_configured_service_types(self):
78+
for service_type_module_path in getattr(settings, "SERVICES_TYPE_MODULES", []):
79+
custom_service_type_module = import_string(service_type_module_path)
80+
for service_type, service_type_config in custom_service_type_module.services_type.items():
81+
self.register(service_type, **service_type_config)
82+
83+
84+
service_type_registry = ServiceTypeRegistry()

0 commit comments

Comments
 (0)