Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CHEF-2415 Azure cli integrated authentication #719

Merged
merged 13 commits into from
Jul 27, 2023
61 changes: 11 additions & 50 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,67 +44,28 @@ This InSpec resource pack uses the Azure REST API and provides the required reso

- Ruby
- Bundler installed
- Azure Service Principal Account

### Configuration

For the driver to interact with the Microsoft Azure Resource Management REST API, you need to configure a Service Principal with Contributor rights for a specific subscription. Using an Organizational (AAD) account and related password is no longer supported.

To create a Service Principal and apply the correct permissions, see the [create an Azure service principal with the Azure CLI](https://docs.microsoft.com/en-us/cli/azure/create-an-azure-service-principal-azure-cli?view=azure-cli-latest#create-a-service-principal) and the [Azure CLI](https://azure.microsoft.com/en-us/documentation/articles/xplat-cli-install/) documentation. Make sure you stay within the section titled 'Password-based authentication'.

If the above is TLDR then try this after `az login` using your target subscription ID and the desired SP name:

### Authentication
### Azure CLI Authentication:
-The Azure CLI provides a command-line interface for interacting with Azure services.

```bash
az ad sp create-for-rbac --name="inspec-azure" --role="Contributor" --scopes="/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
az login --tenant AZURE_TENANT_ID
```
a. Use the `az login --tenant AZURE_TENANT_ID` command to log in with a specific Azure tenant: If you have a specific Azure tenant ID, you can provide it as a parameter to the az login command. If you don't specify the tenant ID, the CLI will provide a list of available tenants.

This above command helps to create the Service Principal account with the given subscription id.

# Output

```bash
{
"appId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"displayName": "azure-cli-2018-12-12-14-15-39",
"name": "http://azure-cli-2018-12-12-14-15-39",
"password": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"tenant": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
```
b. If the CLI can open your default browser: If the CLI can open your default browser, it will initiate the authorization code flow and open the Azure sign-in page in the browser for authentication.

Explanation of the above output:
c. If no web browser is available or fails to open: In case a web browser is not available or fails to open, the CLI will initiate the device code flow. It will provide you with a code and instruct you to open a browser page at https://aka.ms/devicelogin. You need to enter the code displayed in your terminal on that page for authentication.

| Attribute Name | Description |
|----------------|---------------------------------------------------------|
| appId | This is the Client Id of the user. |
| displayName | This is the display name of the Service Principal name. |
| name | This is the name of the Service Principal name. |
| password | This is the Client Secret of the user. |
| tenant | This is the Tenant Id of the user. |

NOTE: Don't forget to save the values from the output -- most importantly the `password`.

You will also need to ensure you have an active Azure subscription (you can get started [for free](https://azure.microsoft.com/en-us/free/) or use your [MSDN Subscription](https://azure.microsoft.com/en-us/pricing/member-offers/msdn-benefits/)).

You are now ready to configure `inspec-azure` to use the credentials from the service principal you created above. You will use four elements from the output:

1. **Subscription ID**: available from the Azure portal
2. **Client ID**: the appId value from the output.
3. **Client Secret/Password**: the password from the output.
4. **Tenant ID**: the tenant from the output.

These must be stored in an environment variables prefaced with `AZURE_`. If you use Dotenv, then you can save these values in your own `.envrc` file. Either source it or run `direnv allow`. If you do not use `Dotenv`, then you can create environment variables in the way that you prefer.
d. Storing retrieved credentials: The documentation suggests storing the retrieved credentials, such as tenant_id and subscription_id, in environment variables prefaced with AZURE_. It provides an example of using a .envrc file or creating environment variables using the preferred method.

```ruby
AZURE_CLIENT_ID=<your-azure-client-id-here>
AZURE_CLIENT_SECRET=<your-client-secret-here>
AZURE_TENANT_ID=<your-azure-tenant-id-here>
SUBSCRIPTION_ID=<your-azure-subscription-id-here>
AZURE_SUBSCRIPTION_ID=<your-azure-subscription-id-here>
```

Note that the environment variables, if set, take preference over the values in a configuration file.

### Below is the manual procedure to create the Service Principal Account
### Azure Service Principal Account Authentication:

### Service Principal

Expand Down
4 changes: 2 additions & 2 deletions libraries/azure_backend.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ class AzureResourceBase < Inspec.resource(1)
def initialize(opts = {})
raise ArgumentError, "Parameters must be provided in an Hash object." unless opts.is_a?(Hash)
@opts = opts

# Populate client_args to specify AzureConnection
#
# The valid client args (all of them are optional):
Expand Down Expand Up @@ -54,8 +53,9 @@ def initialize(opts = {})
raise StandardError, message
end


# We can't raise an error due to `InSpec check` builds up a dummy backend and any error at this stage fails it.
unless @azure.credentials.values.compact.delete_if(&:empty?).size == 4
unless @azure.credentials.values.compact.delete_if(&:empty?).size >= 2
Inspec::Log.error "The following must be set in the Environment:"\
" #{@azure.credentials.keys}.\n"\
"Missing: #{@azure.credentials.keys.select { |key| @azure.credentials[key].nil? }}"
Expand Down
56 changes: 34 additions & 22 deletions libraries/backend/azure_connection.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
require "backend/helpers"

require 'time'
require 'json'
# Client class to manage the Azure REST API connection.
#
# An instance of this class will:
Expand Down Expand Up @@ -146,34 +147,45 @@ def rest_api_call(opts)
#
def authenticate(resource)
# Validate the presence of credentials.
unless credentials.values.compact.delete_if(&:empty?).size == 4
unless credentials.values.compact.delete_if(&:empty?).size >= 2
raise HTTPClientError::MissingCredentials, "The following must be set in the Environment:"\
" #{credentials.keys}.\n"\
"Missing: #{credentials.keys.select { |key| credentials[key].nil? }}"
end
# Build up the url that is required to authenticate with Azure REST API
auth_url = "#{@client_args[:endpoint].active_directory_endpoint_url}#{credentials[:tenant_id]}/oauth2/token"
body = {
grant_type: "client_credentials",
client_id: credentials[:client_id],
client_secret: credentials[:client_secret],
resource: resource,
}
headers = {
"Content-Type" => "application/x-www-form-urlencoded",
"Accept" => "application/json",
}
resp = @connection.post(auth_url) do |req|
req.body = URI.encode_www_form(body)
req.headers = headers
end
if resp.status == 200
response_body = resp.body
@@token_data[resource.to_sym][:token] = response_body[:access_token]
@@token_data[resource.to_sym][:token_expires_on] = Time.at(Integer(response_body[:expires_on]))
@@token_data[resource.to_sym][:token_type] = response_body[:token_type]
if !credentials[:client_secret].nil?
sa-progress marked this conversation as resolved.
Show resolved Hide resolved
body = {
grant_type: "client_credentials",
client_id: credentials[:client_id],
client_secret: credentials[:client_secret],
resource: resource,
}
headers = {
"Content-Type" => "application/x-www-form-urlencoded",
"Accept" => "application/json",
}
resp = @connection.post(auth_url) do |req|
req.body = URI.encode_www_form(body)
req.headers = headers
end

if resp.status == 200
response_body = resp.body
@@token_data[resource.to_sym][:token] = response_body[:access_token]
@@token_data[resource.to_sym][:token_expires_on] = Time.at(Integer(response_body[:expires_on]))
@@token_data[resource.to_sym][:token_type] = response_body[:token_type]
else
fail_api_query(resp)
end
else
fail_api_query(resp)

response = `az account get-access-token`
sa-progress marked this conversation as resolved.
Show resolved Hide resolved
response_body = JSON.parse(response)
@@token_data[resource.to_sym][:token] = response_body["accessToken"]
@@token_data[resource.to_sym][:token_expires_on] = Time.parse(response_body["expiresOn"])
@@token_data[resource.to_sym][:token_type] = response_body["tokenType"]

end
end

Expand Down
159 changes: 159 additions & 0 deletions libraries/backend/azure_credentials.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
require "inifile"
require "kitchen/logging"
autoload :MsRest, "ms_rest"
sa-progress marked this conversation as resolved.
Show resolved Hide resolved

module Inspec
module Driver
#
# AzureCredentials
#
class AzureCredentials
include Kitchen::Logging

CONFIG_PATH = "#{ENV["HOME"]}/.azure/credentials".freeze

#
# @return [String]
#
attr_reader :subscription_id

#
# @return [String]
#
attr_reader :environment

#
# Creates and initializes a new instance of the Credentials class.
#
def initialize(subscription_id:, environment: "Azure")
@subscription_id = subscription_id
@environment = environment
end

#
# Retrieves an object containing options and credentials
#
# @return [Object] Object that can be supplied along with all Azure client requests.
#
def azure_options
options = { tenant_id: tenant_id!,
subscription_id: subscription_id,
credentials: ::MsRest::TokenCredentials.new(token_provider),
active_directory_settings: ad_settings,
base_url: endpoint_settings.resource_manager_endpoint_url }
options[:client_id] = client_id if client_id
options[:client_secret] = client_secret if client_secret
options
end

private

def logger
Kitchen.logger
end

def config_path
@config_path ||= File.expand_path(ENV["AZURE_CONFIG_FILE"] || CONFIG_PATH)
end

def credentials
@credentials ||= if File.file?(config_path)
IniFile.load(config_path)
else
debug "#{config_path} was not found or not accessible."
{}
end
end

def credentials_property(property)
credentials[subscription_id]&.[](property)
end

def tenant_id!
tenant_id || warn("(#{config_path}) does not contain tenant_id neither is the AZURE_TENANT_ID environment variable set.")
end

def tenant_id
ENV["AZURE_TENANT_ID"] || credentials_property("tenant_id")
end

def client_id
ENV["AZURE_CLIENT_ID"] || credentials_property("client_id")
end

def client_secret
ENV["AZURE_CLIENT_SECRET"] || credentials_property("client_secret")
end

# Retrieve a token based upon the preferred authentication method.
#
# @return [::MsRest::TokenProvider] A new token provider object.
def token_provider
# Login with a credentials file or setting the environment variables
#
# Typically used with a service principal.
#
# SPN with client_id, client_secret and tenant_id
if client_id && client_secret && tenant_id
::MsRestAzure::ApplicationTokenProvider.new(tenant_id, client_id, client_secret, ad_settings)
# Login with a Managed Service Identity.
#
# Typically used with a Managed Service Identity when you have a particular object registered in a tenant.
#
# MSI with client_id and tenant_id (aka User Assigned Identity).
elsif client_id && tenant_id
::MsRestAzure::MSITokenProvider.new(50342, ad_settings, { client_id: client_id })
# Default approach to inheriting existing object permissions (application or device this code is running on).
#
# Typically used when you want to inherit the permissions of the system you're running on that are in a tenant.
#
# MSI with just tenant_id (aka System Assigned Identity).
elsif tenant_id
::MsRestAzure::MSITokenProvider.new(50342, ad_settings)
# Login using the Azure CLI
#
# Typically used when you want to rely upon `az login` as your preferred authentication method.
else
warn("Using tenant id set through `az login`.")
::MsRestAzure::AzureCliTokenProvider.new(ad_settings)
end
end

#
# Retrieves a [MsRestAzure::ActiveDirectoryServiceSettings] object representing the AD settings for the given cloud.
#
# @return [MsRestAzure::ActiveDirectoryServiceSettings] Settings to be used for subsequent requests
#
def ad_settings
case environment.downcase
when "azureusgovernment"
::MsRestAzure::ActiveDirectoryServiceSettings.get_azure_us_government_settings
when "azurechina"
::MsRestAzure::ActiveDirectoryServiceSettings.get_azure_china_settings
when "azuregermancloud"
::MsRestAzure::ActiveDirectoryServiceSettings.get_azure_german_settings
when "azure"
::MsRestAzure::ActiveDirectoryServiceSettings.get_azure_settings
end
end

#
# Retrieves a [MsRestAzure::AzureEnvironment] object representing endpoint settings for the given cloud.
#
# @return [MsRestAzure::AzureEnvironment] Settings to be used for subsequent requests
#
def endpoint_settings
case environment.downcase
when "azureusgovernment"
::MsRestAzure::AzureEnvironments::AzureUSGovernment
when "azurechina"
::MsRestAzure::AzureEnvironments::AzureChinaCloud
when "azuregermancloud"
::MsRestAzure::AzureEnvironments::AzureGermanCloud
when "azure"
::MsRestAzure::AzureEnvironments::AzureCloud
end
end
end
end
end