diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 084162cd7..15f78f40c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -7,6 +7,24 @@ Change Log ---------- +8.19.0.1b1 +========== +* wrr / 2026-09-02 / branch: fm/dcicutils-okta-ini-config-7p + - Added first-class Okta values to ``deployment_utils``: ``build_ini_file_from_template`` and + ``build_ini_stream_from_template`` now accept ``okta_issuer``, ``okta_client``, ``okta_scopes``, + and ``okta_require_email_verified``, bound to the ``OKTA_ISSUER``, ``OKTA_CLIENT``, ``OKTA_SCOPES``, + and ``OKTA_REQUIRE_EMAIL_VERIFIED`` template substitutions. Each takes its value from the explicit + argument, else the corresponding ``ENCODED_OKTA_*`` environment variable, else a safe default, + matching the existing Auth0 precedence. This lets a portal container render Okta settings into + ``production.ini`` during its existing one-time startup configuration step, rather than having the + running application read Secrets Manager itself. + - ``OKTA_SCOPES`` defaults to empty so the consuming application picks its own scopes, and + ``OKTA_REQUIRE_EMAIL_VERIFIED`` is omitted from the generated file unless a boolean is actually + supplied, so that the application's secure default (require a verified email) applies. There is + deliberately no Okta secret: this is a public SPA using Authorization Code with PKCE. + - Existing callers, Auth0 values, and generated output are unchanged. + + 8.19.0 ====== * ajs/wrr/sn 2026-07-29 / branch: sn_refactor_custom_excel diff --git a/dcicutils/deployment_utils.py b/dcicutils/deployment_utils.py index 8ab95cc8a..22ea97803 100644 --- a/dcicutils/deployment_utils.py +++ b/dcicutils/deployment_utils.py @@ -40,7 +40,7 @@ def main(): is_fourfront_env, is_cgap_env, is_stg_or_prd_env, is_test_env, is_hotseat_env, is_indexer_env, indexer_env_for_env, full_env_name, ) -from .misc_utils import PRINT, Retry, apply_dict_overrides, override_environ, file_contents +from .misc_utils import PRINT, Retry, apply_dict_overrides, override_environ, file_contents, to_boolean from .env_base import EnvBase, s3Base @@ -428,6 +428,8 @@ def build_ini_file_from_template(cls, template_file_name, init_file_name, *, application_bucket_prefix=None, foursight_bucket_prefix=None, auth0_domain=None, auth0_client=None, auth0_secret=None, auth0_allowed_connections=None, + okta_issuer=None, okta_client=None, okta_scopes=None, + okta_require_email_verified=None, re_captcha_key=None, re_captcha_secret=None, redis_server=None, google_api_key=None, @@ -469,6 +471,13 @@ def build_ini_file_from_template(cls, template_file_name, init_file_name, *, auth0_client (str): A string identifying the auth0 client application. auth0_secret (str): A string secret that is passed with the auth0_client to authenticate that client. auth0_allowed_connections (str): A comma separated string of allowed connections that can be used via auth0. + okta_issuer (str): The Okta issuer (authorization server) URL to validate tokens against. + okta_client (str): The Okta client (application) id. This is a public SPA client, so it has no secret. + okta_scopes (str): A space separated string of OIDC scopes to request. Empty means the application + chooses its own default. + okta_require_email_verified (bool): Whether an Okta identity must have a verified email. If neither this + nor ENCODED_OKTA_REQUIRE_EMAIL_VERIFIED is given, the setting is omitted from the generated .ini file + so that the application's own (secure, true) default applies. re_captcha_key (str): key used for reCaptcha for throttling/detecting humans on login re_captcha_secret (str): secret used for reCaptcha redis_server (str): A server URL to a Redis cluster, for use with sessions @@ -507,6 +516,10 @@ def build_ini_file_from_template(cls, template_file_name, init_file_name, *, auth0_client=auth0_client, auth0_secret=auth0_secret, auth0_allowed_connections=auth0_allowed_connections, + okta_issuer=okta_issuer, + okta_client=okta_client, + okta_scopes=okta_scopes, + okta_require_email_verified=okta_require_email_verified, re_captcha_key=re_captcha_key, re_captcha_secret=re_captcha_secret, redis_server=redis_server, @@ -569,6 +582,29 @@ def omittable(cls, line, expanded_line): PRD_DEFAULT_CREATE_MAPPING_ON_DEPLOY_WIPE_ES = None PRD_DEFAULT_CREATE_MAPPING_ON_DEPLOY_STRICT = None + @classmethod + def okta_require_email_verified_setting(cls, okta_require_email_verified=None): + """ + Returns the string to bind to OKTA_REQUIRE_EMAIL_VERIFIED in an .ini template. + + An explicit argument takes precedence over the ENCODED_OKTA_REQUIRE_EMAIL_VERIFIED environment variable. + The result is "true" or "false" if a boolean value was actually supplied, and otherwise the empty string. + + The empty string is deliberate rather than accidental: an empty expansion makes cls.omittable drop the + assignment line from the generated .ini file entirely, so the consuming application falls back to its own + default, which is to require a verified email. An absent, empty, or unparseable value therefore cannot + turn that check off, and (unlike raising) it cannot break .ini generation at container startup either. + """ + if okta_require_email_verified is None: + okta_require_email_verified = os.environ.get("ENCODED_OKTA_REQUIRE_EMAIL_VERIFIED") + if isinstance(okta_require_email_verified, str): + # to_boolean recognizes only true/t/false/f (case-insensitively), yielding None for anything else, + # so any other spelling is treated as "not specified" and omitted. + okta_require_email_verified = to_boolean(okta_require_email_verified, None) + if okta_require_email_verified is None: + return "" + return "true" if okta_require_email_verified else "false" + @classmethod def build_ini_stream_from_template(cls, template_file_name, init_file_stream, *, bs_env=None, bs_mirror_env=None, s3_bucket_org=None, s3_bucket_env=None, @@ -580,6 +616,8 @@ def build_ini_stream_from_template(cls, template_file_name, init_file_stream, *, application_bucket_prefix=None, foursight_bucket_prefix=None, auth0_domain=None, auth0_client=None, auth0_secret=None, auth0_allowed_connections=None, + okta_issuer=None, okta_client=None, okta_scopes=None, + okta_require_email_verified=None, re_captcha_key=None, re_captcha_secret=None, redis_server=None, google_api_key=None, @@ -618,6 +656,13 @@ def build_ini_stream_from_template(cls, template_file_name, init_file_stream, *, auth0_client (str): A string identifying the auth0 client application. auth0_secret (str): A string secret that is passed with the auth0_client to authenticate that client. auth0_allowed_connections (str): A comma separated string of allowed connections that can be used via auth0. + okta_issuer (str): The Okta issuer (authorization server) URL to validate tokens against. + okta_client (str): The Okta client (application) id. This is a public SPA client, so it has no secret. + okta_scopes (str): A space separated string of OIDC scopes to request. Empty means the application + chooses its own default. + okta_require_email_verified (bool): Whether an Okta identity must have a verified email. If neither this + nor ENCODED_OKTA_REQUIRE_EMAIL_VERIFIED is given, the setting is omitted from the generated .ini file + so that the application's own (secure, true) default applies. re_captcha_key (str): key used for reCaptcha for throttling/detecting humans on login re_captcha_secret (str): secret used for reCaptcha redis_server (str): A server URL to a Redis cluster, for use with sessions @@ -696,6 +741,14 @@ def build_ini_stream_from_template(cls, template_file_name, init_file_stream, *, auth0_secret = auth0_secret or os.environ.get("ENCODED_AUTH0_SECRET", "") auth0_allowed_connections = auth0_allowed_connections or os.environ.get("ENCODED_AUTH0_ALLOWED_CONNECTIONS", "") + # Okta Configuration. + # Note that there is deliberately no Okta secret. The portal's Okta integration is a public SPA using the + # Authorization Code flow with PKCE, which has no client secret to configure or to leak into an .ini file. + okta_issuer = okta_issuer or os.environ.get("ENCODED_OKTA_ISSUER", "") + okta_client = okta_client or os.environ.get("ENCODED_OKTA_CLIENT", "") + okta_scopes = okta_scopes or os.environ.get("ENCODED_OKTA_SCOPES", "") + okta_require_email_verified = cls.okta_require_email_verified_setting(okta_require_email_verified) + # reCatpcha Configuration re_captcha_key = re_captcha_key or os.environ.get('reCaptchaKey', '') re_captcha_secret = re_captcha_secret or os.environ.get('reCaptchaSecret', '') @@ -806,6 +859,10 @@ def build_ini_stream_from_template(cls, template_file_name, init_file_stream, *, 'AUTH0_CLIENT': auth0_client, 'AUTH0_SECRET': auth0_secret, 'AUTH0_ALLOWED_CONNECTIONS': auth0_allowed_connections, + 'OKTA_ISSUER': okta_issuer, + 'OKTA_CLIENT': okta_client, + 'OKTA_SCOPES': okta_scopes, + 'OKTA_REQUIRE_EMAIL_VERIFIED': okta_require_email_verified, 'g.recaptcha.key': re_captcha_key, 'g.recaptcha.secret': re_captcha_secret, 'CREATE_MAPPING_SKIP': create_mapping_on_deploy_skip, diff --git a/pyproject.toml b/pyproject.toml index 031c2913a..e2fc91a1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "dcicutils" -version = "8.19.0" +version = "8.19.0.1b1" description = "Utility package for interacting with the 4DN Data Portal and other 4DN resources" authors = ["4DN-DCIC Team "] license = "MIT" diff --git a/test/test_deployment_utils.py b/test/test_deployment_utils.py index 4f92ff8a7..bfb6fcd54 100644 --- a/test/test_deployment_utils.py +++ b/test/test_deployment_utils.py @@ -1,5 +1,6 @@ import argparse import datetime +import inspect import io import json import os @@ -1649,3 +1650,227 @@ def test_add_argparse_arguments(): assert parser.parse_args([]) == argparse.Namespace() CreateMappingOnDeployManager.add_argparse_arguments(parser=parser) assert parser.parse_args([]) == argparse.Namespace(skip=False, wipe_es=False, strict=False) + + +class TestOktaDeployer(IniFileManager): + TEMPLATE_DIR = os.path.join(_MY_DIR, "ini_files") + PYPROJECT_FILE_NAME = os.path.join(os.path.dirname(_MY_DIR), "pyproject.toml") + APP_KIND = 'smaht' + APP_ORCHESTRATED = True + + +OKTA_TEMPLATE = '\n'.join([ + "[app:app]", + "auth0.domain = ${AUTH0_DOMAIN}", + "auth0.client = ${AUTH0_CLIENT}", + "auth0.secret = ${AUTH0_SECRET}", + "auth0.allowed_connections = ${AUTH0_ALLOWED_CONNECTIONS}", + "okta.issuer = ${OKTA_ISSUER}", + "okta.client = ${OKTA_CLIENT}", + "okta.scopes = ${OKTA_SCOPES}", + "okta.require_email_verified = ${OKTA_REQUIRE_EMAIL_VERIFIED}", + "", +]) + + +# Everything here is incidental to what the Okta tests are checking; it just keeps the builder from having to +# consult EnvUtils or the environment for values these tests don't care about. The env_name has to agree with the +# ambient ENV_NAME, which the builder cross-checks. +_OKTA_BUILD_DEFAULTS = { + 'env_name': os.environ.get('ENV_NAME', 'fourfront-mastertest'), + 'env_bucket': 'test-env-bucket', + 'env_ecosystem': 'main', + 'data_set': 'test', + 's3_bucket_org': 'testorg', + 'es_server': 'es.example.com', + 'higlass_server': 'hg.example.com', +} + + +def _build_okta_ini(**kwargs): + """ + Renders OKTA_TEMPLATE with TestOktaDeployer and returns the resulting text. + + All the machinery that reaches outside the process (the git/EB version probe, the distribution versions, + and the pyproject.toml read) is mocked out, since none of it is what these tests are about. + """ + mfs = MockFileSystem() + with mfs.mock_exists_open_remove(): + with io.open("okta.ini", 'w') as fp: + fp.write(OKTA_TEMPLATE) + with mock.patch.object(IniFileManager, "get_app_version", return_value="v-okta-test"): + with mock.patch.object(deployment_utils_module, "toml") as mock_toml: + mock_toml.load.return_value = {'tool': {'poetry': {'version': MOCKED_PROJECT_VERSION}}} + with mock.patch.object(deployment_utils_module.pkg_resources, "get_distribution", + return_value=FakeDistribution()): + output = StringIO() + TestOktaDeployer.build_ini_stream_from_template("okta.ini", output, + **_OKTA_BUILD_DEFAULTS, **kwargs) + return output.getvalue() + + +def _okta_settings(rendered): + """Returns the okta.* settings of a rendered ini file as a dictionary.""" + return { + key.strip(): value.strip() + for key, _, value in (line.partition('=') for line in rendered.splitlines()) + if key.strip().startswith('okta.') + } + + +def test_deployment_utils_okta_values_from_arguments(): + + settings = _okta_settings(_build_okta_ini(okta_issuer="https://example.okta.com/oauth2/default", + okta_client="0oaSAMPLECLIENT", + okta_scopes="openid email profile", + okta_require_email_verified=True)) + + assert settings == { + 'okta.issuer': "https://example.okta.com/oauth2/default", + 'okta.client': "0oaSAMPLECLIENT", + 'okta.scopes': "openid email profile", + 'okta.require_email_verified': "true", + } + + +def test_deployment_utils_okta_values_from_environment(): + + with override_environ(ENCODED_OKTA_ISSUER="https://env.okta.com/oauth2/default", + ENCODED_OKTA_CLIENT="0oaENVCLIENT", + ENCODED_OKTA_SCOPES="openid email", + ENCODED_OKTA_REQUIRE_EMAIL_VERIFIED="false"): + settings = _okta_settings(_build_okta_ini()) + + assert settings == { + 'okta.issuer': "https://env.okta.com/oauth2/default", + 'okta.client': "0oaENVCLIENT", + 'okta.scopes': "openid email", + 'okta.require_email_verified': "false", + } + + +def test_deployment_utils_okta_arguments_take_precedence_over_environment(): + + with override_environ(ENCODED_OKTA_ISSUER="https://decoy.okta.com/oauth2/default", + ENCODED_OKTA_CLIENT="0oaDECOYCLIENT", + ENCODED_OKTA_SCOPES="openid", + ENCODED_OKTA_REQUIRE_EMAIL_VERIFIED="true"): + settings = _okta_settings(_build_okta_ini(okta_issuer="https://explicit.okta.com/oauth2/default", + okta_client="0oaEXPLICITCLIENT", + okta_scopes="openid email profile", + # An explicit False must not fall through to the environment. + okta_require_email_verified=False)) + + assert settings == { + 'okta.issuer': "https://explicit.okta.com/oauth2/default", + 'okta.client': "0oaEXPLICITCLIENT", + 'okta.scopes': "openid email profile", + 'okta.require_email_verified': "false", + } + + +def test_deployment_utils_okta_omitted_when_absent(): + + # With no Okta arguments and no ENCODED_OKTA_* environment variables, every okta.* line is omitted, + # so that the consuming application applies its own defaults (notably, requiring a verified email). + rendered = _build_okta_ini() + + assert _okta_settings(rendered) == {} + assert 'okta' not in rendered + + # The scopes value is empty rather than defaulted upstream, so the application chooses its own scopes, + # but a partial Okta configuration still doesn't silently disable the email verification requirement. + settings = _okta_settings(_build_okta_ini(okta_issuer="https://example.okta.com/oauth2/default", + okta_client="0oaSAMPLECLIENT")) + assert settings == { + 'okta.issuer': "https://example.okta.com/oauth2/default", + 'okta.client': "0oaSAMPLECLIENT", + } + + +def test_deployment_utils_okta_require_email_verified_setting(): + + # Nothing supplied at all means "omit the line", not "false". + assert IniFileManager.okta_require_email_verified_setting() == "" + assert IniFileManager.okta_require_email_verified_setting(None) == "" + + assert IniFileManager.okta_require_email_verified_setting(True) == "true" + assert IniFileManager.okta_require_email_verified_setting(False) == "false" + + for spelling in ["true", "True", "TRUE", " true ", "t", "T"]: + assert IniFileManager.okta_require_email_verified_setting(spelling) == "true" + for spelling in ["false", "False", "FALSE", " false ", "f", "F"]: + assert IniFileManager.okta_require_email_verified_setting(spelling) == "false" + + # An empty or unparseable value is treated as unspecified (omitted), never as a way to turn the check off. + for spelling in ["", " ", "yes", "no", "0", "1", "maybe"]: + assert IniFileManager.okta_require_email_verified_setting(spelling) == "" + + with override_environ(ENCODED_OKTA_REQUIRE_EMAIL_VERIFIED="false"): + assert IniFileManager.okta_require_email_verified_setting() == "false" + # An explicit argument wins over the environment variable, in both directions. + assert IniFileManager.okta_require_email_verified_setting(True) == "true" + + with override_environ(ENCODED_OKTA_REQUIRE_EMAIL_VERIFIED="bogus"): + assert IniFileManager.okta_require_email_verified_setting() == "" + + +def test_deployment_utils_okta_does_not_disturb_auth0(): + + auth0_values = { + 'auth0_domain': "dummy-domain", + 'auth0_client': "31415926535", + 'auth0_secret': "piepipiepipiepi", + 'auth0_allowed_connections': "github,google", + } + expected_auth0_lines = [ + "auth0.domain = dummy-domain", + "auth0.client = 31415926535", + "auth0.secret = piepipiepipiepi", + "auth0.allowed_connections = github,google", + ] + + without_okta = _build_okta_ini(**auth0_values) + with_okta = _build_okta_ini(okta_issuer="https://example.okta.com/oauth2/default", + okta_client="0oaSAMPLECLIENT", + okta_scopes="openid email profile", + okta_require_email_verified=True, + **auth0_values) + + def auth0_lines(rendered): + return [line for line in rendered.splitlines() if line.startswith('auth0.')] + + # The auth0 lines are byte-for-byte the same whether or not Okta values are supplied. + assert auth0_lines(without_okta) == expected_auth0_lines + assert auth0_lines(with_okta) == expected_auth0_lines + + +def test_deployment_utils_okta_has_no_secret(): + + # Okta here is a public SPA using Authorization Code with PKCE, so there is deliberately no client secret. + for method in [IniFileManager.build_ini_file_from_template, IniFileManager.build_ini_stream_from_template]: + okta_params = [name for name in inspect.signature(method).parameters if name.startswith('okta')] + assert okta_params == ['okta_issuer', 'okta_client', 'okta_scopes', 'okta_require_email_verified'] + + secret_template = "[app:app]\nokta.secret = ${OKTA_SECRET}\nokta.client = ${OKTA_CLIENT}\n" + mfs = MockFileSystem() + with mfs.mock_exists_open_remove(): + with io.open("okta_secret.ini", 'w') as fp: + fp.write(secret_template) + with mock.patch.object(IniFileManager, "get_app_version", return_value="v-okta-test"): + with mock.patch.object(deployment_utils_module, "toml") as mock_toml: + mock_toml.load.return_value = {'tool': {'poetry': {'version': MOCKED_PROJECT_VERSION}}} + with mock.patch.object(deployment_utils_module.pkg_resources, "get_distribution", + return_value=FakeDistribution()): + output = StringIO() + with override_environ(ENCODED_OKTA_SECRET="should-not-be-used", + ENCODED_OKTA_CLIENT="0oaSAMPLECLIENT"): + TestOktaDeployer.build_ini_stream_from_template("okta_secret.ini", output, + **_OKTA_BUILD_DEFAULTS) + rendered = output.getvalue() + + # OKTA_CLIENT is bound, so it expands. OKTA_SECRET is not bound by anything deployment_utils does, so it is + # left in the output unexpanded, and in particular ENCODED_OKTA_SECRET is not picked up from the environment. + assert "okta.client = 0oaSAMPLECLIENT" in rendered + assert "okta.secret = ${OKTA_SECRET}" in rendered + assert 'should-not-be-used' not in rendered