From 1e0ef67ab8ae84d9cf32497154f03fb099124a40 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Wed, 3 Apr 2024 16:07:41 -0500 Subject: [PATCH] PYTHON-3664 OIDC: Automatic token acquisition for GCP Identity Provider (#1540) --- .evergreen/config.yml | 56 +++++++++++++++++++++++-- .evergreen/run-mongodb-oidc-test.sh | 5 ++- pymongo/_gcp_helpers.py | 39 +++++++++++++++++ pymongo/auth.py | 8 ++++ pymongo/auth_oidc.py | 10 +++++ test/auth/legacy/connection-string.json | 29 ++++++++++++- test/auth_oidc/test_auth_oidc.py | 12 ++++-- test/unified_format.py | 5 +++ 8 files changed, 155 insertions(+), 9 deletions(-) create mode 100644 pymongo/_gcp_helpers.py diff --git a/.evergreen/config.yml b/.evergreen/config.yml index 5f3515325..a84b84214 100644 --- a/.evergreen/config.yml +++ b/.evergreen/config.yml @@ -991,6 +991,30 @@ task_groups: tasks: - oidc-auth-test-azure-latest + - name: testgcpoidc_task_group + setup_group: + - func: fetch source + - func: prepare resources + - func: fix absolute paths + - func: make files executable + - command: subprocess.exec + params: + binary: bash + env: + GCPOIDC_VMNAME_PREFIX: "PYTHON_DRIVER" + args: + - ${DRIVERS_TOOLS}/.evergreen/auth_oidc/gcp/setup.sh + teardown_task: + - command: subprocess.exec + params: + binary: bash + args: + - ${DRIVERS_TOOLS}/.evergreen/auth_oidc/gcp/teardown.sh + setup_group_can_fail_task: true + setup_group_timeout_secs: 1800 + tasks: + - oidc-auth-test-gcp-latest + - name: testoidc_task_group setup_group: - func: fetch source @@ -1966,6 +1990,25 @@ tasks: export AZUREOIDC_TEST_CMD="OIDC_ENV=azure ./.evergreen/run-mongodb-oidc-test.sh" bash $DRIVERS_TOOLS/.evergreen/auth_oidc/azure/run-driver-test.sh + - name: "oidc-auth-test-gcp-latest" + commands: + - command: shell.exec + params: + shell: bash + script: |- + set -o errexit + ${PREPARE_SHELL} + cd src + git add . + git commit -m "add files" + export GCPOIDC_DRIVERS_TAR_FILE=/tmp/mongo-python-driver.tgz + git archive -o $GCPOIDC_DRIVERS_TAR_FILE HEAD + # Define the command to run on the VM. + # Ensure that we source the environment file created for us, set up any other variables we need, + # and then run our test suite on the vm. + export GCPOIDC_TEST_CMD="OIDC_ENV=gcp ./.evergreen/run-mongodb-oidc-test.sh" + bash $DRIVERS_TOOLS/.evergreen/auth_oidc/gcp/run-driver-test.sh + - name: "test-fips-standalone" tags: ["fips"] commands: @@ -2995,18 +3038,25 @@ buildvariants: - matrix_name: "oidc-auth-test" matrix_spec: platform: [ rhel8, macos-1100, windows-64-vsMulti-small ] - display_name: "MONGODB-OIDC Auth ${platform}" + display_name: "OIDC Auth ${platform}" tasks: - name: testoidc_task_group batchtime: 20160 # 14 days - name: testazureoidc-variant - display_name: "Azure OIDC" - run_on: ubuntu2004-small + display_name: "OIDC Auth Azure" + run_on: ubuntu2204-small tasks: - name: testazureoidc_task_group batchtime: 20160 # Use a batchtime of 14 days as suggested by the CSFLE test README +- name: testgcpoidc-variant + display_name: "OIDC Auth GCP" + run_on: ubuntu2204-small + tasks: + - name: testgcpoidc_task_group + batchtime: 20160 # Use a batchtime of 14 days as suggested by the CSFLE test README + - matrix_name: "aws-auth-test" matrix_spec: platform: [ubuntu-20.04] diff --git a/.evergreen/run-mongodb-oidc-test.sh b/.evergreen/run-mongodb-oidc-test.sh index 3c045bf7d..89a211930 100755 --- a/.evergreen/run-mongodb-oidc-test.sh +++ b/.evergreen/run-mongodb-oidc-test.sh @@ -1,7 +1,7 @@ #!/bin/bash set +x # Disable debug trace -set -o errexit # Exit the script with error if any of the commands fail +set -eu echo "Running MONGODB-OIDC authentication tests" @@ -18,6 +18,9 @@ if [ $OIDC_ENV == "test" ]; then elif [ $OIDC_ENV == "azure" ]; then source ./env.sh +elif [ $OIDC_ENV == "gcp" ]; then + source ./secrets-export.sh + else echo "Unrecognized OIDC_ENV $OIDC_ENV" exit 1 diff --git a/pymongo/_gcp_helpers.py b/pymongo/_gcp_helpers.py new file mode 100644 index 000000000..67b177dbf --- /dev/null +++ b/pymongo/_gcp_helpers.py @@ -0,0 +1,39 @@ +# Copyright 2024-present MongoDB, 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. + +"""GCP helpers.""" +from __future__ import annotations + +from typing import Any +from urllib.request import Request, urlopen + + +def _get_gcp_response(resource: str, timeout: float = 5) -> dict[str, Any]: + url = "http://metadata/computeMetadata/v1/instance/service-accounts/default/identity" + url += f"?audience={resource}" + headers = {"Metadata-Flavor": "Google", "Accept": "application/json"} + request = Request(url, headers=headers) # noqa: S310 + try: + with urlopen(request, timeout=timeout) as response: # noqa: S310 + status = response.status + body = response.read().decode("utf8") + except Exception as e: + msg = "Failed to acquire IMDS access token: %s" % e + raise ValueError(msg) from None + + if status != 200: + msg = "Failed to acquire IMDS access token." + raise ValueError(msg) + + return dict(access_token=body) diff --git a/pymongo/auth.py b/pymongo/auth.py index 542e2676b..263737d94 100644 --- a/pymongo/auth.py +++ b/pymongo/auth.py @@ -41,6 +41,7 @@ from pymongo.auth_oidc import ( _authenticate_oidc, _get_authenticator, _OIDCAzureCallback, + _OIDCGCPCallback, _OIDCProperties, _OIDCTestCallback, ) @@ -207,6 +208,13 @@ def _build_credentials_tuple( "Azure environment for MONGODB-OIDC requires a TOKEN_RESOURCE auth mechanism property" ) callback = _OIDCAzureCallback(token_resource) + elif environ == "gcp": + passwd = None + if not token_resource: + raise ConfigurationError( + "GCP provider for MONGODB-OIDC requires a TOKEN_RESOURCE auth mechanism property" + ) + callback = _OIDCGCPCallback(token_resource) else: raise ConfigurationError(f"unrecognized ENVIRONMENT for MONGODB-OIDC: {environ}") else: diff --git a/pymongo/auth_oidc.py b/pymongo/auth_oidc.py index 939c4fd95..6455cacb1 100644 --- a/pymongo/auth_oidc.py +++ b/pymongo/auth_oidc.py @@ -26,6 +26,7 @@ import bson from bson.binary import Binary from pymongo._azure_helpers import _get_azure_response from pymongo._csot import remaining +from pymongo._gcp_helpers import _get_gcp_response from pymongo.errors import ConfigurationError, OperationFailure if TYPE_CHECKING: @@ -133,6 +134,15 @@ class _OIDCAzureCallback(OIDCCallback): ) +class _OIDCGCPCallback(OIDCCallback): + def __init__(self, token_resource: str) -> None: + self.token_resource = token_resource + + def fetch(self, context: OIDCCallbackContext) -> OIDCCallbackResult: + resp = _get_gcp_response(self.token_resource, context.timeout_seconds) + return OIDCCallbackResult(access_token=resp["access_token"]) + + @dataclass class _OIDCAuthenticator: username: str diff --git a/test/auth/legacy/connection-string.json b/test/auth/legacy/connection-string.json index 2813e4d1c..50afe7cb5 100644 --- a/test/auth/legacy/connection-string.json +++ b/test/auth/legacy/connection-string.json @@ -540,10 +540,37 @@ "credential": null }, { - "description": "should throw and exception if no token audience is given for azure provider (MONGODB-OIDC)", + "description": "should throw an exception if no token audience is given for azure provider (MONGODB-OIDC)", "uri": "mongodb://username@localhost/?authMechanism=MONGODB-OIDC&authMechanismProperties=ENVIRONMENT:azure", "valid": false, "credential": null + }, + { + "description": "should recognise the mechanism with gcp provider (MONGODB-OIDC)", + "uri": "mongodb://localhost/?authMechanism=MONGODB-OIDC&authMechanismProperties=ENVIRONMENT:gcp,TOKEN_RESOURCE:foo", + "valid": true, + "credential": { + "username": null, + "password": null, + "source": "$external", + "mechanism": "MONGODB-OIDC", + "mechanism_properties": { + "ENVIRONMENT": "gcp", + "TOKEN_RESOURCE": "foo" + } + } + }, + { + "description": "should throw an error for a username and password with gcp provider (MONGODB-OIDC)", + "uri": "mongodb://user:pass@localhost/?authMechanism=MONGODB-OIDC&authMechanismProperties=ENVIRONMENT:gcp,TOKEN_RESOURCE:foo", + "valid": false, + "credential": null + }, + { + "description": "should throw an error if not TOKEN_RESOURCE with gcp provider (MONGODB-OIDC)", + "uri": "mongodb://user:pass@localhost/?authMechanism=MONGODB-OIDC&authMechanismProperties=ENVIRONMENT:gcp", + "valid": false, + "credential": null } ] } diff --git a/test/auth_oidc/test_auth_oidc.py b/test/auth_oidc/test_auth_oidc.py index 0fdfc38eb..9105412fc 100644 --- a/test/auth_oidc/test_auth_oidc.py +++ b/test/auth_oidc/test_auth_oidc.py @@ -27,12 +27,14 @@ from typing import Dict sys.path[0:0] = [""] +import pprint from test.unified_format import generate_test_classes from test.utils import EventListener from bson import SON from pymongo import MongoClient from pymongo._azure_helpers import _get_azure_response +from pymongo._gcp_helpers import _get_gcp_response from pymongo.auth_oidc import ( OIDCCallback, OIDCCallbackResult, @@ -75,10 +77,12 @@ class OIDCTestBase(unittest.TestCase): return fid.read() elif ENVIRON == "azure": opts = parse_uri(self.uri_single)["options"] - resource = opts["authmechanismproperties"]["TOKEN_RESOURCE"] - return _get_azure_response(resource, username)["access_token"] - else: - raise RuntimeError(f"Invalid ENVIRONMENT {ENVIRON}") + token_aud = opts["authmechanismproperties"]["TOKEN_RESOURCE"] + return _get_azure_response(token_aud, username)["access_token"] + elif ENVIRON == "gcp": + opts = parse_uri(self.uri_single)["options"] + token_aud = opts["authmechanismproperties"]["TOKEN_RESOURCE"] + return _get_gcp_response(token_aud, username)["access_token"] @contextmanager def fail_point(self, command_args): diff --git a/test/unified_format.py b/test/unified_format.py index 86056278a..93ef10090 100644 --- a/test/unified_format.py +++ b/test/unified_format.py @@ -172,6 +172,11 @@ elif OIDC_ENV == "azure": "ENVIRONMENT": "azure", "TOKEN_RESOURCE": os.environ["AZUREOIDC_RESOURCE"], } +elif OIDC_ENV == "gcp": + PLACEHOLDER_MAP["/uriOptions/authMechanismProperties"] = { + "ENVIRONMENT": "gcp", + "TOKEN_RESOURCE": os.environ["GCPOIDC_AUDIENCE"], + } def interrupt_loop():