diff --git a/galaxy_ng/app/dynaconf_hooks.py b/galaxy_ng/app/dynaconf_hooks.py index 4c3620ddaa..44e6f8faf3 100755 --- a/galaxy_ng/app/dynaconf_hooks.py +++ b/galaxy_ng/app/dynaconf_hooks.py @@ -34,17 +34,20 @@ ) -def post(settings: Dynaconf) -> Dict[str, Any]: +def post(settings: Dynaconf, run_dynamic: bool = True, run_validate: bool = True) -> Dict[str, Any]: """The dynaconf post hook is called after all the settings are loaded and set. Post hook is necessary when a setting key depends conditionally on a previouslys et variable. settings: A read-only copy of the django.conf.settings + run_dynamic: update the final data with configure_dynamic_settings + run_validate: call the validate function on the final data returns: a dictionary to be merged to django.conf.settings NOTES: Feature flags must be loaded directly on `app/api/ui/views/feature_flags.py` view. """ + data = {"dynaconf_merge": False} # existing keys will be merged if dynaconf_merge is set to True # here it is set to false, so it allows each value to be individually marked as a merge. @@ -61,7 +64,7 @@ def post(settings: Dynaconf) -> Dict[str, Any]: data.update(configure_legacy_roles(settings)) data.update(configure_dab_required_settings(settings)) - # This should go last, and it needs to receive the data from the previous configuration + # These should go last, and it needs to receive the data from the previous configuration # functions because this function configures the rest framework auth classes based off # of the galaxy auth classes, and if galaxy auth classes are overridden by any of the # other dynaconf hooks (such as keycloak), those changes need to be applied to the @@ -70,9 +73,12 @@ def post(settings: Dynaconf) -> Dict[str, Any]: data.update(configure_authentication_classes(settings, data)) # This must go last, so that all the default settings are loaded before dynamic and validation - data.update(configure_dynamic_settings(settings)) + if run_dynamic: + data.update(configure_dynamic_settings(settings)) + + if run_validate: + validate(settings) - validate(settings) return data @@ -104,6 +110,9 @@ def configure_keycloak(settings: Dynaconf) -> Dict[str, Any]: KEYCLOAK_REALM, ] ): + + data["GALAXY_AUTH_KEYCLOAK_ENABLED"] = True + data["KEYCLOAK_ADMIN_ROLE"] = settings.get("KEYCLOAK_ADMIN_ROLE", default="hubadmin") data["KEYCLOAK_GROUP_TOKEN_CLAIM"] = settings.get( "KEYCLOAK_GROUP_TOKEN_CLAIM", default="group" @@ -168,12 +177,7 @@ def configure_keycloak(settings: Dynaconf) -> Dict[str, Any]: "dynaconf_merge", ] - # Replace AUTH CLASSES - data["GALAXY_AUTHENTICATION_CLASSES"] = [ - "galaxy_ng.app.auth.session.SessionAuthentication", - "galaxy_ng.app.auth.token.ExpiringTokenAuthentication", - "galaxy_ng.app.auth.keycloak.KeycloakBasicAuth" - ] + # Replace AUTH CLASSES [shifted to configure_authentication_classes] # Set default to one day expiration data["GALAXY_TOKEN_EXPIRATION"] = settings.get("GALAXY_TOKEN_EXPIRATION", 1440) @@ -402,16 +406,30 @@ def configure_authentication_classes(settings: Dynaconf, data: Dict[str, Any]) - # default rest framework auth classes to the galaxy auth classes. Ideally we should # switch everything to use the default DRF auth classes, but given how many # environments would have to be reconfigured, this is a lot easier. + galaxy_auth_classes = data.get( "GALAXY_AUTHENTICATION_CLASSES", settings.get("GALAXY_AUTHENTICATION_CLASSES", None) ) + if galaxy_auth_classes is None: + galaxy_auth_classes = [] + + # add in keycloak classes if necessary ... + if data.get('GALAXY_AUTH_KEYCLOAK_ENABLED') is True: + for class_name in [ + "galaxy_ng.app.auth.session.SessionAuthentication", + "galaxy_ng.app.auth.token.ExpiringTokenAuthentication", + "galaxy_ng.app.auth.keycloak.KeycloakBasicAuth" + ]: + if class_name not in galaxy_auth_classes: + galaxy_auth_classes.insert(0, class_name) + if galaxy_auth_classes: - return { - "REST_FRAMEWORK__DEFAULT_AUTHENTICATION_CLASSES": galaxy_auth_classes - } - else: - return {} + data["ANSIBLE_AUTHENTICATION_CLASSES"] = list(galaxy_auth_classes) + data["GALAXY_AUTHENTICATION_CLASSES"] = list(galaxy_auth_classes) + data["REST_FRAMEWORK__DEFAULT_AUTHENTICATION_CLASSES"] = list(galaxy_auth_classes) + + return data def configure_password_validators(settings: Dynaconf) -> Dict[str, Any]: diff --git a/galaxy_ng/tests/unit/app/test_dynaconf_hooks.py b/galaxy_ng/tests/unit/app/test_dynaconf_hooks.py new file mode 100644 index 0000000000..da5f6d3f80 --- /dev/null +++ b/galaxy_ng/tests/unit/app/test_dynaconf_hooks.py @@ -0,0 +1,339 @@ +import copy +import pytest + +from galaxy_ng.app.dynaconf_hooks import post as post_hook + + +class SuperDict(dict): + + immutable = False + + def set(self, key, value): + if self.immutable: + raise Exception("not mutable!") + self[key] = value + + def get(self, key, default=None): + return self[key] if key in self else default + + def __getattr__(self, key): + try: + # If key exists in the dictionary, return it + return self[key] + except KeyError: + # Raise an attribute error if the key doesn't exist + raise AttributeError(f"'CustomDict' object has no attribute '{key}'") + + # This is called when setting an attribute that doesn't exist on the object + def __setattr__(self, key, value): + # Assign the value to the dictionary using the key + if self.immutable: + raise Exception("not mutable!") + self[key] = value + + +class SuperValidator: + @staticmethod + def register(*args, **kwargs): + pass + + @staticmethod + def validate(*args, **kwargs): + pass + + +AUTHENTICATION_BACKEND_PRESETS_DATA = { + "ldap": [ + "galaxy_ng.app.auth.ldap.PrefixedLDAPBackend", + # "galaxy_ng.app.auth.ldap.GalaxyLDAPBackend", + "django.contrib.auth.backends.ModelBackend", + "pulpcore.backends.ObjectRolePermissionBackend", + "dynaconf_merge", + ], + "keycloak": [ + "social_core.backends.keycloak.KeycloakOAuth2", + "dynaconf_merge", + ], +} + +BASE_SETTINGS = { + "AUTH_PASSWORD_VALIDATORS": [], + "GALAXY_API_PATH_PREFIX": "/api/galaxy", + "INSTALLED_APPS": [], + "REST_FRAMEWORK": True, + "SPECTACULAR_SETTINGS": True, + "AUTHENTICATION_BACKENDS": [], + "MIDDLEWARE": None, + "AUTHENTICATION_BACKEND_PRESETS_DATA": copy.deepcopy(AUTHENTICATION_BACKEND_PRESETS_DATA), + "BASE_DIR": "templates", + "validators": SuperValidator(), +} + + +@pytest.mark.parametrize( + "do_stuff, extra_settings, expected_results", + [ + # >=4.10 no external auth ... + ( + True, + # False, + {}, + { + "AUTHENTICATION_BACKENDS": [ + "ansible_base.lib.backends.prefixed_user_auth.PrefixedUserAuthBackend" + ] + }, + ), + # >=4.10 ldap ... + ( + True, + # False, + { + "AUTHENTICATION_BACKEND_PRESET": "ldap", + "AUTH_LDAP_SERVER_URI": "ldap://ldap:10389", + "AUTH_LDAP_BIND_DN": "cn=admin,dc=planetexpress,dc=com", + "AUTH_LDAP_BIND_PASSWORD": "GoodNewsEveryone", + "AUTH_LDAP_USER_SEARCH_BASE_DN": "ou=people,dc=planetexpress,dc=com", + "AUTH_LDAP_USER_SEARCH_SCOPE": "SUBTREE", + "AUTH_LDAP_USER_SEARCH_FILTER": "(uid=%(user)s)", + "AUTH_LDAP_GROUP_SEARCH_BASE_DN": "ou=people,dc=planetexpress,dc=com", + "AUTH_LDAP_GROUP_SEARCH_SCOPE": "SUBTREE", + "AUTH_LDAP_GROUP_SEARCH_FILTER": "(objectClass=Group)", + "AUTH_LDAP_USER_ATTR_MAP": { + "first_name": "givenName", + "last_name": "sn", + "email": "mail", + }, + }, + { + "GALAXY_AUTH_LDAP_ENABLED": True, + "AUTH_LDAP_GLOBAL_OPTIONS": {}, + "AUTHENTICATION_BACKENDS": [ + "galaxy_ng.app.auth.ldap.PrefixedLDAPBackend", + "django.contrib.auth.backends.ModelBackend", + "pulpcore.backends.ObjectRolePermissionBackend", + "dynaconf_merge", + "ansible_base.lib.backends.prefixed_user_auth.PrefixedUserAuthBackend", + ], + "ANSIBLE_AUTHENTICATION_CLASSES": None, + "GALAXY_AUTHENTICATION_CLASSES": None, + "REST_FRAMEWORK__DEFAULT_AUTHENTICATION_CLASSES": None, + }, + ), + # >=4.10 keycloak ... + ( + True, + # False, + { + "AUTHENTICATION_BACKEND_PRESET": "keycloak", + "SOCIAL_AUTH_KEYCLOAK_KEY": "xyz", + "SOCIAL_AUTH_KEYCLOAK_SECRET": "abc", + "SOCIAL_AUTH_KEYCLOAK_PUBLIC_KEY": "1234", + "KEYCLOAK_PROTOCOL": "http", + "KEYCLOAK_HOST": "cloak.com", + "KEYCLOAK_PORT": 8080, + "KEYCLOAK_REALM": "aap", + }, + { + "GALAXY_AUTH_KEYCLOAK_ENABLED": True, + "GALAXY_FEATURE_FLAGS__external_authentication": True, + "AUTHENTICATION_BACKENDS": [ + "social_core.backends.keycloak.KeycloakOAuth2", + "dynaconf_merge", + "ansible_base.lib.backends.prefixed_user_auth.PrefixedUserAuthBackend", + ], + "ANSIBLE_AUTHENTICATION_CLASSES": [ + "galaxy_ng.app.auth.keycloak.KeycloakBasicAuth", + "galaxy_ng.app.auth.token.ExpiringTokenAuthentication", + "galaxy_ng.app.auth.session.SessionAuthentication", + ], + "GALAXY_AUTHENTICATION_CLASSES": [ + "galaxy_ng.app.auth.keycloak.KeycloakBasicAuth", + "galaxy_ng.app.auth.token.ExpiringTokenAuthentication", + "galaxy_ng.app.auth.session.SessionAuthentication", + ], + "REST_FRAMEWORK__DEFAULT_AUTHENTICATION_CLASSES": [ + "galaxy_ng.app.auth.keycloak.KeycloakBasicAuth", + "galaxy_ng.app.auth.token.ExpiringTokenAuthentication", + "galaxy_ng.app.auth.session.SessionAuthentication", + ], + }, + ), + # >=4.10 dab .. + ( + True, + # False, + { + "GALAXY_AUTHENTICATION_CLASSES": [ + "galaxy_ng.app.auth.session.SessionAuthentication", + "ansible_base.jwt_consumer.hub.auth.HubJWTAuth", + "rest_framework.authentication.TokenAuthentication", + "rest_framework.authentication.BasicAuthentication", + ] + }, + { + "AUTHENTICATION_BACKENDS": [ + "ansible_base.lib.backends.prefixed_user_auth.PrefixedUserAuthBackend", + ], + "ANSIBLE_AUTHENTICATION_CLASSES": [ + "galaxy_ng.app.auth.session.SessionAuthentication", + "ansible_base.jwt_consumer.hub.auth.HubJWTAuth", + "rest_framework.authentication.TokenAuthentication", + "rest_framework.authentication.BasicAuthentication", + ], + "GALAXY_AUTHENTICATION_CLASSES": [ + "galaxy_ng.app.auth.session.SessionAuthentication", + "ansible_base.jwt_consumer.hub.auth.HubJWTAuth", + "rest_framework.authentication.TokenAuthentication", + "rest_framework.authentication.BasicAuthentication", + ], + "REST_FRAMEWORK__DEFAULT_AUTHENTICATION_CLASSES": [ + "galaxy_ng.app.auth.session.SessionAuthentication", + "ansible_base.jwt_consumer.hub.auth.HubJWTAuth", + "rest_framework.authentication.TokenAuthentication", + "rest_framework.authentication.BasicAuthentication", + ], + }, + ), + # >=4.10 keycloak+dab ... + ( + True, + # False, + { + "AUTHENTICATION_BACKEND_PRESET": "keycloak", + "SOCIAL_AUTH_KEYCLOAK_KEY": "xyz", + "SOCIAL_AUTH_KEYCLOAK_SECRET": "abc", + "SOCIAL_AUTH_KEYCLOAK_PUBLIC_KEY": "1234", + "KEYCLOAK_PROTOCOL": "http", + "KEYCLOAK_HOST": "cloak.com", + "KEYCLOAK_PORT": 8080, + "KEYCLOAK_REALM": "aap", + "GALAXY_AUTHENTICATION_CLASSES": [ + "galaxy_ng.app.auth.session.SessionAuthentication", + "ansible_base.jwt_consumer.hub.auth.HubJWTAuth", + "rest_framework.authentication.TokenAuthentication", + "rest_framework.authentication.BasicAuthentication", + ], + }, + { + "GALAXY_AUTH_KEYCLOAK_ENABLED": True, + "GALAXY_FEATURE_FLAGS__external_authentication": True, + "AUTHENTICATION_BACKENDS": [ + "social_core.backends.keycloak.KeycloakOAuth2", + "dynaconf_merge", + "ansible_base.lib.backends.prefixed_user_auth.PrefixedUserAuthBackend", + ], + "ANSIBLE_AUTHENTICATION_CLASSES": [ + "galaxy_ng.app.auth.keycloak.KeycloakBasicAuth", + "galaxy_ng.app.auth.token.ExpiringTokenAuthentication", + "galaxy_ng.app.auth.session.SessionAuthentication", + "ansible_base.jwt_consumer.hub.auth.HubJWTAuth", + "rest_framework.authentication.TokenAuthentication", + "rest_framework.authentication.BasicAuthentication", + ], + "GALAXY_AUTHENTICATION_CLASSES": [ + "galaxy_ng.app.auth.keycloak.KeycloakBasicAuth", + "galaxy_ng.app.auth.token.ExpiringTokenAuthentication", + "galaxy_ng.app.auth.session.SessionAuthentication", + "ansible_base.jwt_consumer.hub.auth.HubJWTAuth", + "rest_framework.authentication.TokenAuthentication", + "rest_framework.authentication.BasicAuthentication", + ], + "REST_FRAMEWORK__DEFAULT_AUTHENTICATION_CLASSES": [ + "galaxy_ng.app.auth.keycloak.KeycloakBasicAuth", + "galaxy_ng.app.auth.token.ExpiringTokenAuthentication", + "galaxy_ng.app.auth.session.SessionAuthentication", + "ansible_base.jwt_consumer.hub.auth.HubJWTAuth", + "rest_framework.authentication.TokenAuthentication", + "rest_framework.authentication.BasicAuthentication", + ], + }, + ), + # >=4.10 ldap+dab ... + ( + True, + # False, + { + "AUTHENTICATION_BACKEND_PRESET": "ldap", + "AUTH_LDAP_SERVER_URI": "ldap://ldap:10389", + "AUTH_LDAP_BIND_DN": "cn=admin,dc=planetexpress,dc=com", + "AUTH_LDAP_BIND_PASSWORD": "GoodNewsEveryone", + "AUTH_LDAP_USER_SEARCH_BASE_DN": "ou=people,dc=planetexpress,dc=com", + "AUTH_LDAP_USER_SEARCH_SCOPE": "SUBTREE", + "AUTH_LDAP_USER_SEARCH_FILTER": "(uid=%(user)s)", + "AUTH_LDAP_GROUP_SEARCH_BASE_DN": "ou=people,dc=planetexpress,dc=com", + "AUTH_LDAP_GROUP_SEARCH_SCOPE": "SUBTREE", + "AUTH_LDAP_GROUP_SEARCH_FILTER": "(objectClass=Group)", + "AUTH_LDAP_USER_ATTR_MAP": { + "first_name": "givenName", + "last_name": "sn", + "email": "mail", + }, + "GALAXY_AUTHENTICATION_CLASSES": [ + "galaxy_ng.app.auth.session.SessionAuthentication", + "ansible_base.jwt_consumer.hub.auth.HubJWTAuth", + "rest_framework.authentication.TokenAuthentication", + "rest_framework.authentication.BasicAuthentication", + ], + }, + { + "GALAXY_AUTH_LDAP_ENABLED": True, + "AUTH_LDAP_GLOBAL_OPTIONS": {}, + "AUTHENTICATION_BACKENDS": [ + "galaxy_ng.app.auth.ldap.PrefixedLDAPBackend", + "django.contrib.auth.backends.ModelBackend", + "pulpcore.backends.ObjectRolePermissionBackend", + "dynaconf_merge", + "ansible_base.lib.backends.prefixed_user_auth.PrefixedUserAuthBackend", + ], + "ANSIBLE_AUTHENTICATION_CLASSES": [ + "galaxy_ng.app.auth.session.SessionAuthentication", + "ansible_base.jwt_consumer.hub.auth.HubJWTAuth", + "rest_framework.authentication.TokenAuthentication", + "rest_framework.authentication.BasicAuthentication", + ], + "GALAXY_AUTHENTICATION_CLASSES": [ + "galaxy_ng.app.auth.session.SessionAuthentication", + "ansible_base.jwt_consumer.hub.auth.HubJWTAuth", + "rest_framework.authentication.TokenAuthentication", + "rest_framework.authentication.BasicAuthentication", + ], + "REST_FRAMEWORK__DEFAULT_AUTHENTICATION_CLASSES": [ + "galaxy_ng.app.auth.session.SessionAuthentication", + "ansible_base.jwt_consumer.hub.auth.HubJWTAuth", + "rest_framework.authentication.TokenAuthentication", + "rest_framework.authentication.BasicAuthentication", + ], + }, + ), + ], +) +def test_dynaconf_hooks_authentication_backends_and_classes( + do_stuff, + extra_settings, + expected_results +): + + # skip test this way ... + if not do_stuff: + return + + xsettings = SuperDict() + xsettings.update(copy.deepcopy(BASE_SETTINGS)) + if extra_settings: + xsettings.update(copy.deepcopy(extra_settings)) + + # don't allow the downstream to edit this data ... + xsettings.immutable = True + + new_settings = post_hook(xsettings, run_dynamic=True, run_validate=True) + for key, val in expected_results.items(): + """ + try: + assert new_settings[key] == val + except Exception as e: + print(e) + import epdb; epdb.st() + print(e) + """ + assert new_settings.get(key) == val diff --git a/profiles/keycloak/pulp_config.env b/profiles/keycloak/pulp_config.env index 78091c9a90..b53a791b4a 100644 --- a/profiles/keycloak/pulp_config.env +++ b/profiles/keycloak/pulp_config.env @@ -1,4 +1,9 @@ -PULP_GALAXY_AUTHENTICATION_CLASSES=['galaxy_ng.app.auth.session.SessionAuthentication', 'galaxy_ng.app.auth.token.ExpiringTokenAuthentication', 'galaxy_ng.app.auth.keycloak.KeycloakBasicAuth'] +PULP_AUTHENTICATION_BACKEND_PRESET=keycloak + +# PULP_GALAXY_AUTHENTICATION_CLASSES=['galaxy_ng.app.auth.session.SessionAuthentication', 'galaxy_ng.app.auth.token.ExpiringTokenAuthentication', 'galaxy_ng.app.auth.keycloak.KeycloakBasicAuth'] + +PULP_GALAXY_AUTHENTICATION_CLASSES="['galaxy_ng.app.auth.session.SessionAuthentication', 'ansible_base.jwt_consumer.hub.auth.HubJWTAuth', 'galaxy_ng.app.auth.token.ExpiringTokenAuthentication', 'galaxy_ng.app.auth.keycloak.KeycloakBasicAuth']" + PULP_GALAXY_DEPLOYMENT_MODE=standalone PULP_SOCIAL_AUTH_KEYCLOAK_KEY=automation-hub