Skip to content

Commit

Permalink
azure source/dest plugins: Retrieve credential from Vault token endpo…
Browse files Browse the repository at this point in the history
…int (#178)
  • Loading branch information
bobmshannon authored Jun 4, 2024
1 parent 02788a4 commit c7795e1
Show file tree
Hide file tree
Showing 4 changed files with 120 additions and 72 deletions.
96 changes: 39 additions & 57 deletions lemur/plugins/lemur_azure/auth.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,44 @@
from azure.core.exceptions import ClientAuthenticationError
from azure.identity import ClientSecretCredential, CredentialUnavailableError
from flask import current_app
from azure.core.credentials import AccessToken, TokenCredential
from azure.identity import ClientSecretCredential

import hvac
import os

from retrying import retry

class VaultTokenCredential(TokenCredential):
def __init__(self, audience, client, mount_point, role_name):
if not audience:
self.audience = "https://management.azure.com/"
else:
self.audience = audience
self.client = client
self.mount_point = mount_point
self.role_name = role_name

def __eq__(self, other):
return (
self.audience == other.audience
and self.client == other.client
and self.mount_point == other.mount_point
and self.role_name == other.role_name
)

class RetryableClientSecretCredential(ClientSecretCredential):
"""Credential that authenticates a principle using a client secret. Each call to
get_token will be retried continuously until it succeeds or the pre-configured 10-minute
timeout elapses.
"""

def __init__(self, tenant_id, client_id, client_secret, **kwargs):
super().__init__(tenant_id, client_id, client_secret, **kwargs)

@retry(wait_fixed=1000, stop_max_delay=600000)
def get_token(self, *scopes, **kwargs):
return super().get_token(*scopes, **kwargs)
def get_token(self, *scopes, claims=None, tenant_id=None, **kwargs):
payload = {"resource": self.audience}
data = self.client.adapter.get(
"/v1/{mount_point}/token/{role_name}".format(
mount_point=self.mount_point,
role_name=self.role_name,
),
params=payload,
)["data"]
return AccessToken(
token=data["access_token"],
expires_on=data["expires_on"],
)


def get_azure_credential(plugin, options):
def get_azure_credential(audience, plugin, options):
"""
Fetches a credential used for authenticating with the Azure API.
A new credential will be created if one does not already exist.
Expand All @@ -33,35 +49,19 @@ def get_azure_credential(plugin, options):
:param options: options set for the plugin
:return: an Azure credential
"""
if plugin.credential:
try:
plugin.credential.get_token(
"https://management.azure.com/.default"
) # Try to dispense a valid token.
return plugin.credential
except (CredentialUnavailableError, ClientAuthenticationError) as e:
current_app.logger.warning(
f"Failed to re-use existing Azure credential, another one will attempt to "
f"be re-generated: {e}"
)

tenant = plugin.get_option("azureTenant", options)
auth_method = plugin.get_option("authenticationMethod", options)

if auth_method == "hashicorpVault":
mount_point = plugin.get_option("hashicorpVaultMountPoint", options)
role_name = plugin.get_option("hashicorpVaultRoleName", options)
client_id, client_secret = get_oauth_credentials_from_hashicorp_vault(
mount_point, role_name
)
client = hvac.Client(url=os.environ["VAULT_ADDR"])

# It may take up-to 10 minutes for the generated OAuth credentials to become usable due
# to AD replication delay. To account for this, the credential will continuously
# retry generating an access token until it succeeds or 10 minutes elapse.
plugin.credential = RetryableClientSecretCredential(
tenant_id=tenant,
client_id=client_id,
client_secret=client_secret,
plugin.credential = VaultTokenCredential(
audience=audience,
client=client,
mount_point=mount_point,
role_name=role_name,
)
return plugin.credential
elif auth_method == "azureApp":
Expand All @@ -76,21 +76,3 @@ def get_azure_credential(plugin, options):
return plugin.credential

raise Exception("No supported way to authenticate with Azure")


def get_oauth_credentials_from_hashicorp_vault(mount_point, role_name):
"""
Retrieves OAuth credentials from Hashicorp Vault's Azure secrets engine.
:param mount_point: Path the Azure secrets engine is mounted on
:param role_name: Name of the role to fetch credentials for
:returns:
- client_id - OAuth client ID
- client_secret - OAuth client secret
"""
client = hvac.Client(url=os.environ["VAULT_ADDR"])
creds = client.secrets.azure.generate_credentials(
mount_point=mount_point,
name=role_name,
)
return creds["client_id"], creds["client_secret"]
17 changes: 12 additions & 5 deletions lemur/plugins/lemur_azure/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,9 @@ def upload(self, name, body, private_key, cert_chain, options, **kwargs):
)

certificate_client = CertificateClient(
credential=get_azure_credential(self, options),
credential=get_azure_credential(
audience="https://vault.azure.net", plugin=self, options=options
),
vault_url=self.get_option("azureKeyVaultUrl", options),
)
certificate_client.import_certificate(
Expand Down Expand Up @@ -392,7 +394,9 @@ def __init__(self, *args, **kwargs):
def get_certificates(self, options, **kwargs):
certificates = []
certificate_client = CertificateClient(
credential=get_azure_credential(self, options),
credential=get_azure_credential(
audience="https://vault.azure.net", plugin=self, options=options
),
vault_url=self.get_option("azureKeyVaultUrl", options),
)
for prop in certificate_client.list_properties_of_certificates():
Expand Down Expand Up @@ -421,7 +425,9 @@ def get_certificates(self, options, **kwargs):

def get_certificate_by_name(self, certificate_name, options):
certificate_client = CertificateClient(
credential=get_azure_credential(self, options),
credential=get_azure_credential(
audience="https://vault.azure.net", plugin=self, options=options
),
vault_url=self.get_option("azureKeyVaultUrl", options),
)
try:
Expand All @@ -432,8 +438,9 @@ def get_certificate_by_name(self, certificate_name, options):
return None

def get_endpoints(self, options, **kwargs):
credential = get_azure_credential(self, options)

credential = get_azure_credential(
audience="https://management.azure.com", plugin=self, options=options
)
endpoints = []
for subscription in SubscriptionClient(
credential=credential
Expand Down
67 changes: 67 additions & 0 deletions lemur/plugins/lemur_azure/tests/test_azure_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import os
import unittest
from unittest.mock import patch
from azure.core.credentials import AccessToken
from lemur.plugins.lemur_azure.auth import VaultTokenCredential, get_azure_credential
from lemur.plugins.lemur_azure.plugin import AzureDestinationPlugin
from flask import Flask


class TestAzureAuth(unittest.TestCase):
def setUp(self):
_app = Flask("lemur_test_azure_auth")
self.ctx = _app.app_context()
assert self.ctx
self.ctx.push()

def tearDown(self):
self.ctx.pop()

@patch.dict(os.environ, {"VAULT_ADDR": "https://fakevaultinstance:8200"})
@patch("hvac.Client")
def test_get_azure_credential(self, hvac_client_mock):
client = hvac_client_mock()
client.adapter.get.return_value = {
"request_id": "f7dcd09c-dde9-fa0d-e98e-e4f238dfe66e",
"lease_id": "",
"renewable": False,
"lease_duration": 0,
"data": {
"access_token": "faketoken123",
"expires_in": 14399,
"expires_on": 1717182214,
"not_before": 1717167514,
"refresh_token": "",
"resource": "https://vault.azure.net/",
"token_type": "Bearer",
},
"wrap_info": None,
"warnings": None,
"auth": None,
}
plugin = AzureDestinationPlugin()
options = [
{"name": "azureKeyVaultUrl", "value": "https://couldbeanyvalue.com"},
{"name": "azureTenant", "value": "mockedTenant"},
{"name": "authenticationMethod", "value": "hashicorpVault"},
{"name": "hashicorpVaultRoleName", "value": "mockedRole"},
{"name": "hashicorpVaultMountPoint", "value": "azure"},
]
cred = get_azure_credential(
audience="https://management.azure.com", plugin=plugin, options=options
)
assert cred == VaultTokenCredential(
audience="https://management.azure.com",
client=client,
mount_point="azure",
role_name="mockedRole",
)
access_token = cred.get_token()
client.adapter.get.assert_called_with(
"/v1/azure/token/mockedRole",
params={"resource": "https://management.azure.com"},
)
assert access_token == AccessToken(
token="faketoken123",
expires_on=1717182214,
)
12 changes: 2 additions & 10 deletions lemur/plugins/lemur_azure/tests/test_azure_dest.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,7 @@ def tearDown(self):

@patch.dict(os.environ, {"VAULT_ADDR": "https://fakevaultinstance:8200"})
@patch("azure.keyvault.certificates.CertificateClient.import_certificate")
@patch("hvac.Client")
def test_upload(self, hvac_client_mock, import_certificate_mock):
def test_upload(self, import_certificate_mock):
from lemur.plugins.lemur_azure.plugin import AzureDestinationPlugin

subject = AzureDestinationPlugin()
Expand Down Expand Up @@ -131,14 +130,7 @@ def _assert_certificate_imported():
{"name": "azureTenant", "value": "mockedTenant"},
{"name": "authenticationMethod", "value": "hashicorpVault"},
{"name": "hashicorpVaultRoleName", "value": "mockedRole"},
{"name": "hashicorpVaultMountPoint", "value": "/azure"},
{"name": "hashicorpVaultMountPoint", "value": "azure"},
]
hvac_client_mock().secrets.azure.generate_credentials.return_value = {
"client_id": "fakeid123",
"client_secret": "fakesecret123",
}
subject.upload(name, body, private_key, cert_chain, options)
hvac_client_mock().secrets.azure.generate_credentials.assert_called_with(
mount_point="/azure", name="mockedRole"
)
_assert_certificate_imported()

0 comments on commit c7795e1

Please sign in to comment.