Skip to content

Commit

Permalink
Merge pull request #719 from inspec/RESOURCE-84-azure-cli-integrated-…
Browse files Browse the repository at this point in the history
…authentication

CHEF-2415 Azure cli integrated authentication
  • Loading branch information
sa-progress authored Jul 27, 2023
2 parents c4d0673 + 2c86ccf commit 26684f0
Show file tree
Hide file tree
Showing 5 changed files with 81 additions and 99 deletions.
8 changes: 4 additions & 4 deletions .expeditor/verify.pipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@ expeditor:

steps:

- label: lint-ruby-3.0
- label: lint-ruby-3.1
command:
- RAKE_TASK=lint /workdir/.expeditor/buildkite/verify.sh
expeditor:
executor:
docker:
image: ruby:3.0
image: ruby:3.1

- label: run-tests-ruby-2.7
command:
Expand All @@ -22,11 +22,11 @@ steps:
docker:
image: ruby:2.7-buster

- label: run-tests-ruby-3.0
- label: run-tests-ruby-3.1
command:
- CI_ENABLE_COVERAGE=1 /workdir/.expeditor/buildkite/verify.sh
expeditor:
secrets: true
executor:
docker:
image: ruby:3.0
image: ruby:3.1
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.
To enable authentication, you will need to install the Azure CLI.- [https://learn.microsoft.com/en-us/cli/azure/install-azure-cli](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli)
```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
106 changes: 63 additions & 43 deletions libraries/backend/azure_connection.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
require "backend/helpers"

require 'backend/helpers'
require 'time'
require 'json'
# Client class to manage the Azure REST API connection.
#
# An instance of this class will:
Expand All @@ -15,7 +16,7 @@ class AzureConnection
@@provider_details = {}

# This will be included in headers for statistical purposes.
INSPEC_USER_AGENT = "pid-18d63047-6cdf-4f34-beed-62f01fc73fc2".freeze
INSPEC_USER_AGENT = 'pid-18d63047-6cdf-4f34-beed-62f01fc73fc2'.freeze

# @return [String] the resource management endpoint url
attr_reader :resource_manager_endpoint_url
Expand All @@ -38,7 +39,7 @@ class AzureConnection
# Creates a HTTP client.
def initialize(client_args)
# Validate parameter's type.
raise ArgumentError, "Parameters must be provided in an Hash object." unless client_args.is_a?(Hash)
raise ArgumentError, 'Parameters must be provided in an Hash object.' unless client_args.is_a?(Hash)

# The valid client args:
# - endpoint: [String]
Expand All @@ -49,7 +50,7 @@ def initialize(client_args)
# in order to provide back-off (default - 1)
@client_args = client_args

raise StandardError, "Endpoint has to be provided to establish a connection with Azure REST API." \
raise StandardError, 'Endpoint has to be provided to establish a connection with Azure REST API.' \
unless @client_args.key?(:endpoint)
@resource_manager_endpoint_url = @client_args[:endpoint].resource_manager_endpoint_url
@resource_manager_endpoint_api_version = @client_args[:endpoint].resource_manager_endpoint_api_version
Expand All @@ -75,10 +76,10 @@ def initialize(client_args)
def credentials
# azure://<user>:<password>@<host>/<path>
@credentials ||= {
tenant_id: creds_from_uri[:host] || ENV["AZURE_TENANT_ID"],
client_id: creds_from_uri[:user] || ENV["AZURE_CLIENT_ID"],
client_secret: creds_from_uri[:password] || ENV["AZURE_CLIENT_SECRET"],
subscription_id: creds_from_uri[:path]&.gsub("/", "") || ENV["AZURE_SUBSCRIPTION_ID"],
tenant_id: creds_from_uri[:host] || ENV['AZURE_TENANT_ID'],
client_id: creds_from_uri[:user] || ENV['AZURE_CLIENT_ID'],
client_secret: creds_from_uri[:password] || ENV['AZURE_CLIENT_SECRET'],
subscription_id: creds_from_uri[:path]&.gsub('/', '') || ENV['AZURE_SUBSCRIPTION_ID'],
}
end

Expand Down Expand Up @@ -111,18 +112,18 @@ def rest_api_call(opts)
resource = opts[:audience] || "#{uri.scheme}://#{uri.host}"

# If it is a paged response than the provided nextLink will contain `skiptoken` in parameters.
unless opts[:url].include?("skiptoken")
unless opts[:url].include?('skiptoken')
# Authenticate if necessary.
authenticate(resource) if @@token_data[resource.to_sym].nil? || @@token_data[resource.to_sym].empty?
# Update access token if expired.
authenticate(resource) if Time.now > @@token_data[resource.to_sym][:token_expires_on]
end
# Create the necessary headers.
opts[:headers] ||= {}
opts[:headers]["User-Agent"] = INSPEC_USER_AGENT
opts[:headers]["Authorization"] = "#{@@token_data[resource.to_sym][:token_type]} #{@@token_data[resource.to_sym][:token]}"
opts[:headers]["Accept"] = "application/json"
opts[:method] ||= "get"
opts[:headers]['User-Agent'] = INSPEC_USER_AGENT
opts[:headers]['Authorization'] = "#{@@token_data[resource.to_sym][:token_type]} #{@@token_data[resource.to_sym][:token]}"
opts[:headers]['Accept'] = 'application/json'
opts[:method] ||= 'get'
resp = send_request(opts)

if resp.status == 200
Expand All @@ -146,34 +147,53 @@ def rest_api_call(opts)
#
def authenticate(resource)
# Validate the presence of credentials.
unless credentials.values.compact.delete_if(&:empty?).size == 4
raise HTTPClientError::MissingCredentials, "The following must be set in the Environment:"\
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].present?
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)
begin
response = `az account get-access-token`
rescue StandardError => e
puts "An error occurred which execution az cli command: #{e.message}"
raise HTTPClientError::MissingCLICredentials, 'Wrong TENANT_ID CLIENT_ID CLIENT_SECRET SUBSCRIPTION_ID or did not execute az login with correct tenant id'
end
unless response.nil? || !response.empty?
raise raise HTTPClientError::MissingCLICredentials, 'Wrong TENANT_ID CLIENT_ID CLIENT_SECRET SUBSCRIPTION_ID or did not execute az login with correct tenant id'
end

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 All @@ -199,9 +219,9 @@ def fail_api_query(resp, message = nil)
end
message += code.nil? ? "#{code} #{error_message}" : resp.body.to_s
resource_not_found_codes = %w{Request_ResourceNotFound ResourceGroupNotFound ResourceNotFound NotFound}
resource_not_found_keywords = ["could not be found"]
wrong_api_keywords = ["The supported api-versions are", "The supported versions are", "Consider using the latest supported version"]
explicit_invalid_api_code = "InvalidApiVersionParameter"
resource_not_found_keywords = ['could not be found']
wrong_api_keywords = ['The supported api-versions are', 'The supported versions are', 'Consider using the latest supported version']
explicit_invalid_api_code = 'InvalidApiVersionParameter'
possible_invalid_api_codes = %w{InvalidApiVersionParameter NoRegisteredProviderFound InvalidResourceType BadParameter}
code = code.to_s
if code
Expand All @@ -225,18 +245,18 @@ def fail_api_query(resp, message = nil)

def send_request(opts)
case opts[:method]
when "get"
when 'get'
@connection.get(opts[:url]) do |req|
req.params = opts[:params] unless opts[:params].nil?
req.headers = opts[:headers].merge(opts[:headers]) unless opts[:headers].nil?
end
when "post"
when 'post'
@connection.post(opts[:url]) do |req|
req.params = opts[:params] unless opts[:params].nil?
req.headers = opts[:headers].merge(opts[:headers]) unless opts[:headers].nil?
req.body = opts[:req_body] unless opts[:req_body].nil?
end
when "head"
when 'head'
@connection.head(opts[:url]) do |req|
req.params = opts[:params] unless opts[:params].nil?
req.headers = opts[:headers] unless opts[:headers].nil?
Expand All @@ -251,7 +271,7 @@ def send_request(opts)
def creds_from_uri
return @creds_from_uri if defined? @creds_from_uri

if ENV["RAKE_ENV"] == "test"
if ENV['RAKE_ENV'] == 'test'
Inspec::Config.mock.unpack_train_credentials
else
begin
Expand Down
1 change: 1 addition & 0 deletions libraries/backend/helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ def suggested_api_version(wrong_api_version = nil)

class HTTPClientError < StandardError
class MissingCredentials < StandardError; end
class MissingCLICredentials < StandardError; end
end

# Create necessary Azure environment variables and provide access to them
Expand Down

0 comments on commit 26684f0

Please sign in to comment.