diff --git a/poetry.lock b/poetry.lock index 8afe4267a..34c4719ec 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.4 and should not be changed by hand. [[package]] name = "adal" @@ -931,7 +931,7 @@ bcrypt = ["bcrypt"] [[package]] name = "django-ansible-base" -version = "2026.4.9.0.dev6+g20369e155" +version = "2026.4.29.0.dev15+gd4ea85ee1" description = "A Django app used by ansible services" optional = false python-versions = ">=3.11" @@ -970,7 +970,7 @@ testing = ["cryptography", "pytest", "pytest-django"] type = "git" url = "https://github.com/ansible/django-ansible-base.git" reference = "devel" -resolved_reference = "20369e155486d685dbcf475f3e48c10690144fea" +resolved_reference = "d4ea85ee104f82a4ad535fde6ee25e3087365f12" [[package]] name = "django-crum" @@ -3453,4 +3453,4 @@ dev = ["psycopg-binary"] [metadata] lock-version = "2.1" python-versions = ">=3.11,<3.13" -content-hash = "16ce9a161b8b5964e3fbd199bfcfdf3d8ded61765be91e0045516595462ca89e" +content-hash = "2a87a666ebb199a2211279ce8fe58c4d7ddc752ccd10cd9522a062a092aa8232" diff --git a/pyproject.toml b/pyproject.toml index bf3f7f1f7..24814792a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,6 +78,7 @@ awx-plugins-core = { version = "^0.0.1a10", extras = [ "credentials-centrify-vault-kv", "credentials-conjur", "credentials-github-app", + "credentials-oauth2-client-credentials", "credentials-hashivault-kv", "credentials-hashivault-ssh", "credentials-thycotic-dsv", diff --git a/src/aap_eda/core/enums.py b/src/aap_eda/core/enums.py index aa2e3a007..bf468a300 100644 --- a/src/aap_eda/core/enums.py +++ b/src/aap_eda/core/enums.py @@ -114,6 +114,7 @@ class DefaultCredentialType(DjangoStrEnum): CYBERARK_CONJUR = "CyberArk Conjur Secrets Manager Lookup" MSFT_AZURE_VAULT = "Microsoft Azure Key Vault" GITHUB_APP = "GitHub App Installation Access Token Lookup" + OAUTH2_CLIENT_CREDENTIALS = "OAuth2 Client Credentials Token Lookup" # TODO: rename to "RulebookProcessStatus" or "ParentProcessStatus" diff --git a/src/aap_eda/core/management/commands/create_initial_data.py b/src/aap_eda/core/management/commands/create_initial_data.py index f65b952b6..6e06d6231 100644 --- a/src/aap_eda/core/management/commands/create_initial_data.py +++ b/src/aap_eda/core/management/commands/create_initial_data.py @@ -1778,6 +1778,51 @@ "required": ["app_or_client_id", "install_id", "private_rsa_key"], } +OAUTH2_CLIENT_CREDENTIALS_INPUTS = { + "fields": [ + { + "id": "token_url", + "label": "Token Endpoint URL", + "type": "string", + "help_text": ( + "The full OAuth2 token endpoint URL. " + "For Microsoft Entra ID: " + "https://login.microsoftonline.com/" + "/oauth2/v2.0/token" + ), + }, + { + "id": "client_id", + "label": "Client ID", + "type": "string", + "help_text": ( + "The OAuth2 client identifier " + "(Application ID for Microsoft Entra)." + ), + }, + { + "id": "client_secret", + "label": "Client Secret", + "type": "string", + "secret": True, + "help_text": "The OAuth2 client secret.", + }, + ], + "metadata": [ + { + "id": "scope", + "label": "Scope (optional)", + "type": "string", + "help_text": ( + "The OAuth2 scope to request. " + "For Azure DevOps: " + "499b84ac-1321-427f-aa17-267ca6975798/.default" + ), + }, + ], + "required": ["token_url", "client_id", "client_secret"], +} + EVENT_STREAM_MTLS_INPUTS = { "fields": [ { @@ -2314,6 +2359,14 @@ "injectors": {}, "managed": True, }, + { + "name": enums.DefaultCredentialType.OAUTH2_CLIENT_CREDENTIALS, + "namespace": "oauth2_client_credentials", + "inputs": OAUTH2_CLIENT_CREDENTIALS_INPUTS, + "kind": "external", + "injectors": {}, + "managed": True, + }, { "name": enums.EventStreamCredentialType.MTLS, "namespace": "event_stream", diff --git a/src/aap_eda/core/utils/credential_plugins.py b/src/aap_eda/core/utils/credential_plugins.py index f80160c58..322b60084 100644 --- a/src/aap_eda/core/utils/credential_plugins.py +++ b/src/aap_eda/core/utils/credential_plugins.py @@ -26,6 +26,9 @@ hashivault_kv_plugin, hashivault_ssh_plugin, ) +from awx_plugins.credentials.oauth2 import ( + oauth2_client_credentials_plugin, +) from awx_plugins.credentials.tss import tss_plugin from aap_eda.core.exceptions import ( @@ -44,6 +47,7 @@ "github_app": github_app_lookup, "hashivault_kv": hashivault_kv_plugin, "hashivault_ssh": hashivault_ssh_plugin, + "oauth2_client_credentials": oauth2_client_credentials_plugin, "thycotic_dsv": dsv_plugin, "thycotic_tss": tss_plugin, } diff --git a/tests/unit/commands/test_create_initial_data.py b/tests/unit/commands/test_create_initial_data.py index 7bcf4ab37..2fcbf0753 100644 --- a/tests/unit/commands/test_create_initial_data.py +++ b/tests/unit/commands/test_create_initial_data.py @@ -23,6 +23,7 @@ from aap_eda.core import enums, models from aap_eda.core.management.commands.create_initial_data import ( + OAUTH2_CLIENT_CREDENTIALS_INPUTS, POSTGRES_CREDENTIAL_INPUTS, ) from aap_eda.core.utils.credentials import inputs_from_store @@ -379,3 +380,28 @@ def test_create_initial_data_rule_engine_cred_with_rootcert(): decoded_inputs = inputs_from_store(raw_inputs) assert "rule-engine-rootcert" in decoded_inputs["postgres_sslrootcert"] + + +@pytest.mark.django_db +def test_create_initial_data_oauth2_client_credentials_type(): + """Test that the OAuth2 Client Credentials credential type is seeded.""" + call_command("create_initial_data") + cred_type = models.CredentialType.objects.get( + name=enums.DefaultCredentialType.OAUTH2_CLIENT_CREDENTIALS + ) + assert cred_type.managed is True + assert cred_type.kind == "external" + assert cred_type.namespace == "oauth2_client_credentials" + assert cred_type.inputs == OAUTH2_CLIENT_CREDENTIALS_INPUTS + assert cred_type.injectors == {} + + field_ids = [f["id"] for f in cred_type.inputs["fields"]] + assert "token_url" in field_ids + assert "client_id" in field_ids + assert "client_secret" in field_ids + + secret_field = next( + f for f in cred_type.inputs["fields"] + if f["id"] == "client_secret" + ) + assert secret_field["secret"] is True diff --git a/tests/unit/test_credential_plugins.py b/tests/unit/test_credential_plugins.py index da3809a62..3da0e4b8e 100644 --- a/tests/unit/test_credential_plugins.py +++ b/tests/unit/test_credential_plugins.py @@ -36,6 +36,7 @@ "github_app", "hashivault_kv", "hashivault_ssh", + "oauth2_client_credentials", "thycotic_dsv", "thycotic_tss", ], diff --git a/tests/unit/test_oauth2_credential_plugin.py b/tests/unit/test_oauth2_credential_plugin.py new file mode 100644 index 000000000..157f8a2a0 --- /dev/null +++ b/tests/unit/test_oauth2_credential_plugin.py @@ -0,0 +1,148 @@ +"""Tests for the OAuth2 Client Credentials credential plugin in EDA.""" +# Copyright 2026 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from unittest import mock + +import pytest +import responses +from awx_plugins.credentials.plugin import CredentialPlugin + +from aap_eda.core.enums import DefaultCredentialType +from aap_eda.core.utils.credential_plugins import ( + PLUGIN_TYPES, + run_plugin, +) + + +TOKEN_URL = ( + "https://login.microsoftonline.com/" + "00000000-0000-0000-0000-000000000000/oauth2/v2.0/token" +) +CLIENT_ID = "11111111-1111-1111-1111-111111111111" +CLIENT_SECRET = "test-secret-value" +FAKE_TOKEN = "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.fake.token" + + +class TestOAuth2PluginRegistration: + """Verify the plugin is wired into the EDA plugin registry.""" + + def test_plugin_key_in_plugin_types(self): + assert "oauth2_client_credentials" in PLUGIN_TYPES + + def test_plugin_is_credential_plugin_instance(self): + plugin = PLUGIN_TYPES["oauth2_client_credentials"] + assert isinstance(plugin, CredentialPlugin) + + def test_plugin_name_matches_enum(self): + plugin = PLUGIN_TYPES["oauth2_client_credentials"] + assert ( + plugin.name + == DefaultCredentialType.OAUTH2_CLIENT_CREDENTIALS + ) + + def test_plugin_backend_is_callable(self): + plugin = PLUGIN_TYPES["oauth2_client_credentials"] + assert callable(plugin.backend) + + +class TestOAuth2RunPlugin: + """Verify the run_plugin dispatcher routes correctly.""" + + def test_run_plugin_calls_backend(self): + with mock.patch.object( + CredentialPlugin, "backend" + ) as mock_backend: + mock_backend.return_value = "token-value" + result = run_plugin( + "oauth2_client_credentials", {}, {} + ) + assert result == "token-value" + + @responses.activate + def test_run_plugin_real_backend_success(self): + responses.post( + TOKEN_URL, + json={ + "access_token": FAKE_TOKEN, + "token_type": "Bearer", + "expires_in": 3600, + }, + status=200, + ) + + result = run_plugin( + "oauth2_client_credentials", + { + "token_url": TOKEN_URL, + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + }, + {"scope": ""}, + ) + + assert result == FAKE_TOKEN + + @responses.activate + def test_run_plugin_real_backend_with_scope(self): + ado_scope = ( + "499b84ac-1321-427f-aa17-267ca6975798/.default" + ) + responses.post( + TOKEN_URL, + json={ + "access_token": FAKE_TOKEN, + "token_type": "Bearer", + "expires_in": 3600, + }, + status=200, + ) + + result = run_plugin( + "oauth2_client_credentials", + { + "token_url": TOKEN_URL, + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + }, + {"scope": ado_scope}, + ) + + assert result == FAKE_TOKEN + request_body = responses.calls[0].request.body + assert "499b84ac" in request_body + + @responses.activate + def test_run_plugin_real_backend_failure(self): + responses.post( + TOKEN_URL, + json={ + "error": "invalid_client", + "error_description": "Bad credentials.", + }, + status=401, + ) + + from aap_eda.core.exceptions import CredentialPluginError + + with pytest.raises(CredentialPluginError): + run_plugin( + "oauth2_client_credentials", + { + "token_url": TOKEN_URL, + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + }, + {}, + )