Skip to content

Commit 3745e0b

Browse files
authored
(GH-94) Support for service principal authentication (#93)
Added ability to utilize service_principal_credentials for both hiera lookup function and the puppet 4 function.
1 parent 3d4a06c commit 3745e0b

File tree

7 files changed

+231
-17
lines changed

7 files changed

+231
-17
lines changed

README.md

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,15 @@
1111

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

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

3335
* Puppet Agent 6.0.0 or later.
3436
* Azure Subscription with one or more vaults already created and loaded with secrets.
35-
* Puppet Server running on a machine with Managed Service Identity ( MSI ) and assigned the appropriate permissions
37+
* One of the following authentication strategies
38+
* Managed Service Identity ( MSI )
39+
* Puppet Server running on a machine with Managed Service Identity ( MSI ) and assigned the appropriate permissions
3640
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
41+
* Service Principal
42+
* 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
43+
44+
# Managed Service Identity (MSI) vs Service Principal Credentials
45+
46+
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.
3747

3848
## How it works
3949

4050
### Puppet Function
4151

42-
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:
52+
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.
53+
54+
#### Using Managed Service Identity ( MSI )
4355

4456
```puppet
4557
$important_secret = azure_key_vault::secret('production-vault', 'important-secret', {
46-
metadata_api_version => '2018-04-02',
4758
vault_api_version => '2016-10-01',
59+
metadata_api_version => '2018-04-02',
4860
})
4961
```
5062

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

72+
73+
#### Using Service Principal Credentials
74+
75+
76+
```puppet
77+
$important_secret = azure_key_vault::secret('production-vault', 'important-secret', {
78+
vault_api_version => '2016-10-01',
79+
service_principal_credentials => {
80+
tenant_id => '00000000-0000-1234-1234-000000000000',
81+
client_id => '00000000-0000-1234-1234-000000000000',
82+
client_secret => lookup('azure_client_secret'),
83+
}
84+
})
85+
```
86+
87+
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.
88+
6089
### Hiera Backend
6190

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

93+
94+
#### Using Managed Service Identity ( MSI )
95+
6496
Add a new entry to the `hierarchy` hash in `hiera.yaml` providing the following required lookup options:
6597

6698
```yaml
@@ -77,6 +109,33 @@ Add a new entry to the `hierarchy` hash in `hiera.yaml` providing the following
77109
- '^password.*'
78110
```
79111
112+
#### Using Service Principal Credentials
113+
114+
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.
115+
116+
```yaml
117+
- name: 'Azure Key Vault Secrets'
118+
lookup_key: azure_key_vault::lookup
119+
options:
120+
vault_name: production-vault
121+
vault_api_version: '2016-10-01'
122+
service_principal_credentials: '/etc/puppetlabs/puppet/azure_key_vault_credentials.yaml'
123+
key_replacement_token: '-'
124+
confine_to_keys:
125+
- '^azure_.*'
126+
- '^.*_password$'
127+
- '^password.*'
128+
129+
```
130+
131+
Below is the format of the file that is expected to contain your service principal credentials.
132+
133+
```yaml
134+
tenant_id: '00000000-0000-1234-1234-000000000000'
135+
client_id: '00000000-0000-1234-1234-000000000000'
136+
client_secret: some-secret
137+
```
138+
80139
To retrieve a secret in puppet code you can use the `lookup` function:
81140

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

172+
**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**
173+
113174
### What is confine_to_keys?
114175

115176
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.

lib/puppet/functions/azure_key_vault/lookup.rb

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,14 @@
33
Puppet::Functions.create_function(:'azure_key_vault::lookup') do
44
dispatch :lookup_key do
55
param 'Variant[String, Numeric]', :secret_name
6-
param 'Struct[{vault_name => String, vault_api_version => String, metadata_api_version => String, confine_to_keys => Array[String], Optional[key_replacement_token] => String}]', :options
6+
param 'Struct[{
7+
vault_name => String,
8+
vault_api_version => String,
9+
Optional[metadata_api_version] => String,
10+
confine_to_keys => Array[String],
11+
Optional[key_replacement_token] => String,
12+
Optional[service_principal_credentials] => String
13+
}]', :options
714
param 'Puppet::LookupContext', :context
815
return_type 'Variant[Sensitive, Undef]'
916
end
@@ -35,7 +42,21 @@ def lookup_key(secret_name, options, context)
3542
return Puppet::Pops::Types::PSensitiveType::Sensitive.new(context.cached_value(normalized_secret_name)) if context.cache_has_key(normalized_secret_name)
3643
access_token = context.cached_value('access_token')
3744
if access_token.nil?
38-
access_token = TragicCode::Azure.get_access_token(options['metadata_api_version'])
45+
metadata_api_version = options['metadata_api_version']
46+
service_principal_credentials = options['service_principal_credentials']
47+
if metadata_api_version && service_principal_credentials
48+
raise ArgumentError, 'metadata_api_version and service_principal_credentials cannot be used together'
49+
end
50+
if !metadata_api_version && !service_principal_credentials
51+
raise ArgumentError, 'must configure at least one of metadata_api_version or service_principal_credentials'
52+
end
53+
54+
if service_principal_credentials
55+
credentials = YAML.load_file(service_principal_credentials)
56+
access_token = TragicCode::Azure.get_access_token_service_principal(credentials)
57+
else
58+
access_token = TragicCode::Azure.get_access_token(metadata_api_version)
59+
end
3960
context.cache('access_token', access_token)
4061
end
4162
begin

lib/puppet/functions/azure_key_vault/secret.rb

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,34 +4,61 @@
44
Puppet::Functions.create_function(:'azure_key_vault::secret', Puppet::Functions::InternalFunction) do
55
# @param vault_name Name of the vault in your Azure subscription.
66
# @param secret_name Name of the secret to be retrieved.
7-
# @param api_versions_hash A Hash of the exact versions of the metadata_api_version and vault_api_version to use.
7+
# @param api_endpoint_hash A Hash with API endpoint and authentication information
88
# @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.
99
# @return [Sensitive[String]] Returns the secret as a String wrapped with the Sensitive data type.
1010
dispatch :secret do
1111
cache_param
1212
required_param 'String', :vault_name
1313
required_param 'String', :secret_name
14-
required_param 'Hash', :api_versions_hash
14+
param 'Struct[{
15+
vault_api_version => String,
16+
Optional[metadata_api_version] => String,
17+
Optional[service_principal_credentials] => Struct[{
18+
tenant_id => String,
19+
client_id => String,
20+
client_secret => String
21+
}]
22+
}]', :api_endpoint_hash
1523
optional_param 'String', :secret_version
1624
return_type 'Sensitive[String]'
1725
end
1826

19-
def secret(cache, vault_name, secret_name, api_versions_hash, secret_version = '')
27+
def secret(cache, vault_name, secret_name, api_endpoint_hash, secret_version = '')
2028
Puppet.debug("vault_name: #{vault_name}")
2129
Puppet.debug("secret_name: #{secret_name}")
2230
Puppet.debug("secret_version: #{secret_version}")
23-
Puppet.debug("metadata_api_version: #{api_versions_hash['metadata_api_version']}")
24-
Puppet.debug("vault_api_version: #{api_versions_hash['vault_api_version']}")
31+
Puppet.debug("metadata_api_version: #{api_endpoint_hash['metadata_api_version']}")
32+
Puppet.debug("vault_api_version: #{api_endpoint_hash['vault_api_version']}")
33+
if api_endpoint_hash['service_principal_credentials']
34+
partial_credentials = api_endpoint_hash['service_principal_credentials'].slice('tenant_id', 'client_id')
35+
Puppet.debug("service_principal_credentials: #{partial_credentials}")
36+
end
2537
cache_hash = cache.retrieve(self)
26-
unless cache_hash.key?(:access_token)
38+
access_token_id = :"access_token_#{vault_name}"
39+
unless cache_hash.key?(access_token_id)
2740
Puppet.debug("retrieving access token since it's not in the cache")
28-
cache_hash[:access_token] = TragicCode::Azure.get_access_token(api_versions_hash['metadata_api_version'])
41+
metadata_api_version = api_endpoint_hash['metadata_api_version']
42+
service_principal_credentials = api_endpoint_hash['service_principal_credentials']
43+
if metadata_api_version && service_principal_credentials
44+
raise ArgumentError, 'metadata_api_version and service_principal_credentials cannot be used together'
45+
end
46+
47+
if service_principal_credentials
48+
access_token = TragicCode::Azure.get_access_token_service_principal(service_principal_credentials)
49+
elsif metadata_api_version
50+
access_token = TragicCode::Azure.get_access_token(metadata_api_version)
51+
else
52+
raise ArgumentError, 'hash must contain at least one of metadata_api_version or service_principal_credentials'
53+
end
54+
cache_hash[access_token_id] = access_token
2955
end
56+
3057
secret_value = TragicCode::Azure.get_secret(
3158
vault_name,
3259
secret_name,
33-
api_versions_hash['vault_api_version'],
34-
cache_hash[:access_token],
60+
api_endpoint_hash['vault_api_version'],
61+
cache_hash[access_token_id],
3562
secret_version,
3663
)
3764

lib/puppet_x/tragiccode/azure.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,22 @@ def self.get_access_token(api_version)
1919
JSON.parse(res.body)['access_token']
2020
end
2121

22+
def self.get_access_token_service_principal(credentials)
23+
uri = URI("https://login.microsoftonline.com/#{credentials.fetch('tenant_id')}/oauth2/v2.0/token")
24+
data = {
25+
'grant_type': 'client_credentials',
26+
'client_id': credentials.fetch('client_id'),
27+
'client_secret': credentials.fetch('client_secret'),
28+
'scope': 'https://vault.azure.net/.default'
29+
}
30+
req = Net::HTTP::Post.new(uri.request_uri)
31+
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
32+
http.request(req, URI.encode_www_form(data))
33+
end
34+
raise res.body unless res.is_a?(Net::HTTPSuccess)
35+
JSON.parse(res.body)['access_token']
36+
end
37+
2238
def self.get_secret(vault_name, secret_name, vault_api_version, access_token, secret_version)
2339
version_parameter = secret_version.empty? ? secret_version : "/#{secret_version}"
2440
uri = URI("https://#{vault_name}.vault.azure.net/secrets/#{secret_name}#{version_parameter}?api-version=#{vault_api_version}")

spec/functions/azure_key_vault_lookup_spec.rb

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
'confine_to_keys' => ['^.*sensitive_azure.*'],
1010
}
1111
end
12+
1213
let(:lookup_context) do
1314
environment = instance_double('environment')
1415
allow(environment).to receive(:name).and_return('production')
@@ -27,6 +28,7 @@
2728
it 'accepts 3 required arguments' do
2829
is_expected.to run.with_params.and_raise_error(ArgumentError, %r{expects 3 arguments}i)
2930
end
31+
3032
it 'validates the :options hash' do
3133
is_expected.to run.with_params(
3234
'profile::windows::sqlserver::sensitive_azure_sql_user_password', { 'key1' => 'value1' }, lookup_context
@@ -40,6 +42,7 @@
4042

4143
expect(subject.execute('profile::windows::sqlserver::sensitive_azure_sql_user_password', options, lookup_context).unwrap).to eq 'value'
4244
end
45+
4346
# rubocop:enable RSpec/NamedSubject
4447

4548
# rubocop:disable RSpec/NamedSubject
@@ -65,7 +68,7 @@
6568
end
6669

6770
# rubocop:disable RSpec/NamedSubject
68-
it 'uses - as the default key_replacement_token' do
71+
it "uses '-' as the default key_replacement_token" do
6972
secret_name = 'profile::windows::sqlserver::sensitive_azure_sql_user_password'
7073
access_token_value = 'access_value'
7174
secret_value = 'secret_value'
@@ -83,6 +86,20 @@
8386
).and_raise_error(ArgumentError, %r{'confine_to_keys' expects an Array value}i)
8487
end
8588

89+
it "errors when using both 'metadata_api_version' and 'service_principal_credentials'" do
90+
is_expected.to run.with_params(
91+
'profile::windows::sqlserver::sensitive_azure_sql_user_password', options.merge({ 'service_principal_credentials' => 'path' }), lookup_context
92+
).and_raise_error(ArgumentError, %r{metadata_api_version and service_principal_credentials cannot be used together}i)
93+
end
94+
95+
it "errors when missing both 'metadata_api_version' and 'service_principal_credentials'" do
96+
bad_options = options
97+
bad_options.delete('metadata_api_version')
98+
is_expected.to run.with_params(
99+
'profile::windows::sqlserver::sensitive_azure_sql_user_password', bad_options, lookup_context
100+
).and_raise_error(ArgumentError, %r{must configure at least one of metadata_api_version or service_principal_credentials}i)
101+
end
102+
86103
it 'errors when passing invalid regexes' do
87104
is_expected.to run.with_params(
88105
'profile::windows::sqlserver::sensitive_azure_sql_user_password', options.merge({ 'confine_to_keys' => ['['] }), lookup_context

spec/functions/azure_key_vault_secret_spec.rb

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,37 @@
1414
let(:access_token) { 'random-access-token' }
1515
let(:secret_version) { 'a7f7es9a7d' }
1616

17+
PuppetSensitive = Puppet::Pops::Types::PSensitiveType::Sensitive
18+
1719
it { is_expected.not_to eq(nil) }
1820

19-
context 'when passed the wrong number of arguments' do
20-
it { is_expected.to run.with_params.and_raise_error(ArgumentError, %r{expects between 3 and 4 arguments}i) }
21+
context 'when passed the wrong arguments' do
22+
it 'errors when wrong number of arguments' do
23+
is_expected.to run.with_params.and_raise_error(ArgumentError, %r{expects between 3 and 4 arguments}i)
24+
end
25+
26+
it "errors when using both 'metadata_api_version' and 'service_principal_credentials'" do
27+
bad_hash = {
28+
'metadata_api_version' => 'test',
29+
'vault_api_version' => 'test',
30+
'service_principal_credentials' => {
31+
'tenant_id' => 'test_tenant',
32+
'client_id' => 'test_client',
33+
'client_secret' => 'test_secret'
34+
}
35+
}
36+
is_expected.to run.with_params(
37+
vault_name, secret_name, bad_hash
38+
).and_raise_error(%r{metadata_api_version and service_principal_credentials cannot be used together})
39+
end
40+
41+
it "errors when missing both 'metadata_api_version' and 'service_principal_credentials'" do
42+
bad_hash = api_versions_hash
43+
bad_hash.delete('metadata_api_version')
44+
is_expected.to run.with_params(
45+
vault_name, secret_name, bad_hash
46+
).and_raise_error(%r{hash must contain at least one of metadata_api_version or service_principal_credentials})
47+
end
2148
end
2249

2350
context 'when getting the latest version of a secret' do
@@ -74,4 +101,34 @@
74101
subject.execute(vault_name, secret_name, api_versions_hash)
75102
end
76103
# rubocop:enable RSpec/NamedSubject
104+
105+
context 'authenticating with service principal' do
106+
let(:api_versions_hash) do
107+
{
108+
'vault_api_version' => 'test_version',
109+
'service_principal_credentials' => {
110+
'tenant_id' => 'test_tenant',
111+
'client_id' => 'test_client',
112+
'client_secret' => 'test_secret'
113+
}
114+
}
115+
end
116+
117+
it 'returns the secret' do
118+
expect(TragicCode::Azure).to receive(:get_access_token_service_principal).with(api_versions_hash['service_principal_credentials']).and_return(access_token)
119+
expect(TragicCode::Azure).to receive(:get_secret).with(vault_name, secret_name, api_versions_hash['vault_api_version'], access_token, '').and_return(secret_value)
120+
121+
is_expected.to run.with_params(vault_name, secret_name, api_versions_hash).and_return(PuppetSensitive.new(secret_value))
122+
end
123+
124+
# rubocop:disable RSpec/NamedSubject
125+
it 'retrieves access_token from cache' do
126+
expect(TragicCode::Azure).to receive(:get_access_token_service_principal).and_return(access_token).once
127+
expect(TragicCode::Azure).to receive(:get_secret).and_return(secret_value).twice
128+
129+
subject.execute(vault_name, secret_name, api_versions_hash)
130+
subject.execute(vault_name, secret_name, api_versions_hash)
131+
end
132+
# rubocop:enable RSpec/NamedSubject
133+
end
77134
end

0 commit comments

Comments
 (0)