8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org).

## [v3.1.0](https://github.com/tragiccode/tragiccode-azure_key_vault/tree/v3.1.0) (2022-09-23)

[Full Changelog](https://github.com/tragiccode/tragiccode-azure_key_vault/compare/v3.0.0...v3.1.0)

### Added

- \(GH-94\) Support for service principal authentication [\#93](https://github.com/TraGicCode/tragiccode-azure_key_vault/pull/93) ([kev-in-shu](https://github.com/kev-in-shu))

## [v3.0.0](https://github.com/tragiccode/tragiccode-azure_key_vault/tree/v3.0.0) (2022-08-14)

[Full Changelog](https://github.com/tragiccode/tragiccode-azure_key_vault/compare/v2.1.0...v3.0.0)
Expand Down
67 changes: 64 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@

1. [Description](#description)
1. [Setup](#setup)
1. [Managed Service Identity (MSI) vs Service Principal Credentials](#managed-service-identity-msi-vs-service-principal-credentials)
1. [How it works](#how-it-works)
* [Puppet Function](#puppet-function)
* [Hiera Backend](#hiera-backend)
1. [How it's secure by default](#how-its-secure-by-default)
1. [Usage](#usage)
* [Embedding a secret in a file](#embedding-a-secret-in-a-file)
* [Retrieving a specific version of a secret](#retrieving-a-specific-version-of-a-secret)
* [Retrieving a certificate](#retrieving-a-certificate)
1. [Reference - An under-the-hood peek at what the module is doing and how](#reference)
1. [Development - Guide for contributing to the module](#development)

Expand All @@ -32,19 +34,29 @@ The module requires the following:

* Puppet Agent 6.0.0 or later.
* Azure Subscription with one or more vaults already created and loaded with secrets.
* Puppet Server running on a machine with Managed Service Identity ( MSI ) and assigned the appropriate permissions
* One of the following authentication strategies
* Managed Service Identity ( MSI )
* Puppet Server running on a machine with Managed Service Identity ( MSI ) and assigned the appropriate permissions
to pull secrets from the vault. To learn more or get help with this please visit https://docs.microsoft.com/en-us/azure/active-directory/managed-service-identity/tutorial-windows-vm-access-nonaad
* Service Principal
* Following the required steps to setup a Service Principal. To learn more or get help with this please visit https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app

# Managed Service Identity (MSI) vs Service Principal Credentials

This module provides 2 ways for users to authenticate with azure key vault and pull secrets. These 2 options are Managed Service Identity ( MSI ) and Service Principal Credentials. We highly recommend you utilize Managed Service Identity over service principal credentials whenever possible. This is because you do not have to manage and secure a file on our machines that contain credentials! In some cases, Managed Service Identity ( MSI ) might not be an option for you. One example of this is if your Puppet server and some of your puppet agents are not hosted in Azure. In that case, you can create a Service Principal in Azure Active Directory, assign the appropriate permissions to this Service Principal, and both the function and Hiera Backend provided in this module can authenticate to Azure Keyvault using the credentials of this Service Principal.

## How it works

### Puppet Function

This module contains a Puppet 4 function that allows you to securely retrieve secrets from Azure Key Vault. In order to get started simply call the function in your manifests passing in the required parameters:
This module contains a Puppet 4 function that allows you to securely retrieve secrets from Azure Key Vault. In order to get started simply call the function in your manifests passing in the required parameters.

#### Using Managed Service Identity ( MSI )

```puppet
$important_secret = azure_key_vault::secret('production-vault', 'important-secret', {
metadata_api_version => '2018-04-02',
vault_api_version => '2016-10-01',
metadata_api_version => '2018-04-02',
})
```

Expand All @@ -57,10 +69,30 @@ In the above example the api_versions hash is important. It is pinning both of
* Instance Metadata Service Versions ( https://docs.microsoft.com/en-us/azure/virtual-machines/windows/instance-metadata-service )
* Vault Versions ( TBD )


#### Using Service Principal Credentials


```puppet
$important_secret = azure_key_vault::secret('production-vault', 'important-secret', {
vault_api_version => '2016-10-01',
service_principal_credentials => {
tenant_id => '00000000-0000-1234-1234-000000000000',
client_id => '00000000-0000-1234-1234-000000000000',
client_secret => lookup('azure_client_secret'),
}
})
```

This example show how to utilize service principal credentials if you for some reason are unable to use Managed Service Identity ( MSI ) at your organization. The client_secret must be of type "Sensitive". Please ensure you configure hiera to return the value wrapped in this type as this is what the secret function expects to ensure there is possibilty of leaking the client_secret.

### Hiera Backend

This module contains a Hiera 5 backend that allows you to securely retrieve secrets from Azure key vault and use them in hiera.


#### Using Managed Service Identity ( MSI )

Add a new entry to the `hierarchy` hash in `hiera.yaml` providing the following required lookup options:

```yaml
Expand All @@ -77,6 +109,33 @@ Add a new entry to the `hierarchy` hash in `hiera.yaml` providing the following
- '^password.*'
```
#### Using Service Principal Credentials
To utilize service principal credentials in hiera simply replace `metadata_api_version` with `service_principal_credentials` and ensure it points to a valid yaml file that contains the service principal credentials you would like to use.

```yaml
- name: 'Azure Key Vault Secrets'
lookup_key: azure_key_vault::lookup
options:
vault_name: production-vault
vault_api_version: '2016-10-01'
service_principal_credentials: '/etc/puppetlabs/puppet/azure_key_vault_credentials.yaml'
key_replacement_token: '-'
confine_to_keys:
- '^azure_.*'
- '^.*_password$'
- '^password.*'
```

Below is the format of the file that is expected to contain your service principal credentials.

```yaml
tenant_id: '00000000-0000-1234-1234-000000000000'
client_id: '00000000-0000-1234-1234-000000000000'
client_secret: some-secret
```

To retrieve a secret in puppet code you can use the `lookup` function:

```puppet
Expand Down Expand Up @@ -110,6 +169,8 @@ Alternatively a custom trusted fact can be included [in the certificate request]
- '^password.*'
```

**NOTE: While the above examples show manual lookups happening, it's recommended and considered a best practice to utilize Hiera's automatic parameter lookup (APL) within your puppet code**

### What is confine_to_keys?

By design, hiera will traverse the configured heiarchy for a given key until one is found. This means that there can be a potentially large number of web requests against azure key vault. In order to improve performance and prevent hitting the Azure KeyVault rate limits ( ex: currently there is a maximum of 2,000 lookups every 10 seconds allowed against a key vault), the confine_to_keys allows you to provide an array of regexs that help avoid making a remote call.
Expand Down
42 changes: 36 additions & 6 deletions REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,14 @@ Type: Ruby 4.x API

The azure_key_vault::lookup function.

#### `azure_key_vault::lookup(Variant[String, Numeric] $secret_name, Struct[{vault_name => String, vault_api_version => String, metadata_api_version => String, confine_to_keys => Array[String], Optional[key_replacement_token] => String}] $options, Puppet::LookupContext $context)`
#### `azure_key_vault::lookup(Variant[String, Numeric] $secret_name, Struct[{
vault_name => String,
vault_api_version => String,
Optional[metadata_api_version] => String,
confine_to_keys => Array[String],
Optional[key_replacement_token] => String,
Optional[service_principal_credentials] => String
}] $options, Puppet::LookupContext $context)`

The azure_key_vault::lookup function.

Expand All @@ -31,7 +38,14 @@ Data type: `Variant[String, Numeric]`

##### `options`

Data type: `Struct[{vault_name => String, vault_api_version => String, metadata_api_version => String, confine_to_keys => Array[String], Optional[key_replacement_token] => String}]`
Data type: `Struct[{
vault_name => String,
vault_api_version => String,
Optional[metadata_api_version] => String,
confine_to_keys => Array[String],
Optional[key_replacement_token] => String,
Optional[service_principal_credentials] => String
}]`



Expand All @@ -47,7 +61,15 @@ Type: Ruby 4.x API

Retrieves secrets from Azure's Key Vault.

#### `azure_key_vault::secret(String $vault_name, String $secret_name, Hash $api_versions_hash, Optional[String] $secret_version)`
#### `azure_key_vault::secret(String $vault_name, String $secret_name, Struct[{
vault_api_version => String,
Optional[metadata_api_version] => String,
Optional[service_principal_credentials] => Struct[{
tenant_id => String,
client_id => String,
client_secret => String
}]
}] $api_endpoint_hash, Optional[String] $secret_version)`

Retrieves secrets from Azure's Key Vault.

Expand All @@ -65,11 +87,19 @@ Data type: `String`

Name of the secret to be retrieved.

##### `api_versions_hash`
##### `api_endpoint_hash`

Data type: `Hash`
Data type: `Struct[{
vault_api_version => String,
Optional[metadata_api_version] => String,
Optional[service_principal_credentials] => Struct[{
tenant_id => String,
client_id => String,
client_secret => String
}]
}]`

A Hash of the exact versions of the metadata_api_version and vault_api_version to use.
A Hash with API endpoint and authentication information

##### `secret_version`

Expand Down
25 changes: 23 additions & 2 deletions lib/puppet/functions/azure_key_vault/lookup.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@
Puppet::Functions.create_function(:'azure_key_vault::lookup') do
dispatch :lookup_key do
param 'Variant[String, Numeric]', :secret_name
param 'Struct[{vault_name => String, vault_api_version => String, metadata_api_version => String, confine_to_keys => Array[String], Optional[key_replacement_token] => String}]', :options
param 'Struct[{
vault_name => String,
vault_api_version => String,
Optional[metadata_api_version] => String,
confine_to_keys => Array[String],
Optional[key_replacement_token] => String,
Optional[service_principal_credentials] => String
}]', :options
param 'Puppet::LookupContext', :context
return_type 'Variant[Sensitive, Undef]'
end
Expand Down Expand Up @@ -35,7 +42,21 @@ def lookup_key(secret_name, options, context)
return Puppet::Pops::Types::PSensitiveType::Sensitive.new(context.cached_value(normalized_secret_name)) if context.cache_has_key(normalized_secret_name)
access_token = context.cached_value('access_token')
if access_token.nil?
access_token = TragicCode::Azure.get_access_token(options['metadata_api_version'])
metadata_api_version = options['metadata_api_version']
service_principal_credentials = options['service_principal_credentials']
if metadata_api_version && service_principal_credentials
raise ArgumentError, 'metadata_api_version and service_principal_credentials cannot be used together'
end
if !metadata_api_version && !service_principal_credentials
raise ArgumentError, 'must configure at least one of metadata_api_version or service_principal_credentials'
end

if service_principal_credentials
credentials = YAML.load_file(service_principal_credentials)
access_token = TragicCode::Azure.get_access_token_service_principal(credentials)
else
access_token = TragicCode::Azure.get_access_token(metadata_api_version)
end
context.cache('access_token', access_token)
end
begin
Expand Down
45 changes: 36 additions & 9 deletions lib/puppet/functions/azure_key_vault/secret.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,34 +4,61 @@
Puppet::Functions.create_function(:'azure_key_vault::secret', Puppet::Functions::InternalFunction) do
# @param vault_name Name of the vault in your Azure subscription.
# @param secret_name Name of the secret to be retrieved.
# @param api_versions_hash A Hash of the exact versions of the metadata_api_version and vault_api_version to use.
# @param api_endpoint_hash A Hash with API endpoint and authentication information
# @param secret_version The version of the secret you want to retrieve. This parameter is optional and if not passed the default behavior is to retrieve the latest version.
# @return [Sensitive[String]] Returns the secret as a String wrapped with the Sensitive data type.
dispatch :secret do
cache_param
required_param 'String', :vault_name
required_param 'String', :secret_name
required_param 'Hash', :api_versions_hash
param 'Struct[{
vault_api_version => String,
Optional[metadata_api_version] => String,
Optional[service_principal_credentials] => Struct[{
tenant_id => String,
client_id => String,
client_secret => String
}]
}]', :api_endpoint_hash
optional_param 'String', :secret_version
return_type 'Sensitive[String]'
end

def secret(cache, vault_name, secret_name, api_versions_hash, secret_version = '')
def secret(cache, vault_name, secret_name, api_endpoint_hash, secret_version = '')
Puppet.debug("vault_name: #{vault_name}")
Puppet.debug("secret_name: #{secret_name}")
Puppet.debug("secret_version: #{secret_version}")
Puppet.debug("metadata_api_version: #{api_versions_hash['metadata_api_version']}")
Puppet.debug("vault_api_version: #{api_versions_hash['vault_api_version']}")
Puppet.debug("metadata_api_version: #{api_endpoint_hash['metadata_api_version']}")
Puppet.debug("vault_api_version: #{api_endpoint_hash['vault_api_version']}")
if api_endpoint_hash['service_principal_credentials']
partial_credentials = api_endpoint_hash['service_principal_credentials'].slice('tenant_id', 'client_id')
Puppet.debug("service_principal_credentials: #{partial_credentials}")
end
cache_hash = cache.retrieve(self)
unless cache_hash.key?(:access_token)
access_token_id = :"access_token_#{vault_name}"
unless cache_hash.key?(access_token_id)
Puppet.debug("retrieving access token since it's not in the cache")
cache_hash[:access_token] = TragicCode::Azure.get_access_token(api_versions_hash['metadata_api_version'])
metadata_api_version = api_endpoint_hash['metadata_api_version']
service_principal_credentials = api_endpoint_hash['service_principal_credentials']
if metadata_api_version && service_principal_credentials
raise ArgumentError, 'metadata_api_version and service_principal_credentials cannot be used together'
end

if service_principal_credentials
access_token = TragicCode::Azure.get_access_token_service_principal(service_principal_credentials)
elsif metadata_api_version
access_token = TragicCode::Azure.get_access_token(metadata_api_version)
else
raise ArgumentError, 'hash must contain at least one of metadata_api_version or service_principal_credentials'
end
cache_hash[access_token_id] = access_token
end

secret_value = TragicCode::Azure.get_secret(
vault_name,
secret_name,
api_versions_hash['vault_api_version'],
cache_hash[:access_token],
api_endpoint_hash['vault_api_version'],
cache_hash[access_token_id],
secret_version,
)

Expand Down
16 changes: 16 additions & 0 deletions lib/puppet_x/tragiccode/azure.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,22 @@ def self.get_access_token(api_version)
JSON.parse(res.body)['access_token']
end

def self.get_access_token_service_principal(credentials)
uri = URI("https://login.microsoftonline.com/#{credentials.fetch('tenant_id')}/oauth2/v2.0/token")
data = {
'grant_type': 'client_credentials',
'client_id': credentials.fetch('client_id'),
'client_secret': credentials.fetch('client_secret'),
'scope': 'https://vault.azure.net/.default'
}
req = Net::HTTP::Post.new(uri.request_uri)
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req, URI.encode_www_form(data))
end
raise res.body unless res.is_a?(Net::HTTPSuccess)
JSON.parse(res.body)['access_token']
end

def self.get_secret(vault_name, secret_name, vault_api_version, access_token, secret_version)
version_parameter = secret_version.empty? ? secret_version : "/#{secret_version}"
uri = URI("https://#{vault_name}.vault.azure.net/secrets/#{secret_name}#{version_parameter}?api-version=#{vault_api_version}")
Expand Down
2 changes: 1 addition & 1 deletion metadata.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "tragiccode-azure_key_vault",
"version": "3.0.0",
"version": "3.1.0",
"author": "tragiccode",
"summary": "The azure_key_vault module allows you to easily fetch secrets securely within your puppet manifests.",
"license": "Apache-2.0",
Expand Down
Loading