Skip to content

Commit

Permalink
Adds Authentication using IAM role (#586)
Browse files Browse the repository at this point in the history
This PR adds IAM authentication to the core.  This allows an organization to leverage a host's existing IAM roles to authenticate with Conjur.
  • Loading branch information
jvanderhoof authored Jun 25, 2018
1 parent 17848f0 commit f3f636f
Show file tree
Hide file tree
Showing 6 changed files with 250 additions and 0 deletions.
78 changes: 78 additions & 0 deletions app/domain/authentication/authn_iam/authenticator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
require 'json'

module Authentication
module AuthnIam
class Authenticator

InvalidAWSHeaders = ::Util::ErrorClass.new(
"'Invalid or Expired AWS Headers: {0}")

def initialize(env:)
@env = env
end

def valid?(input)

signed_aws_headers = JSON.parse input.password # input.password is JSON holding the AWS signed headers

response_hash = identity_hash(response_from_signed_request(signed_aws_headers))
trusted = response_hash != false

trusted && iam_role_matches?(input.username, response_hash)

end

def identity_hash(response)

Rails.logger.debug("AWS IAM get_caller_identity body\n#{response.body} ")

if response.code < 300
Hash.from_xml(response.body)
else
Rails.logger.error("Verification of IAM identity failed with HTTP code: #{response.code}")
false
end

end

def iam_role_matches?(login, response_hash)

is_allowed_role = false

split_assumed_role = response_hash["GetCallerIdentityResponse"]["GetCallerIdentityResult"]["Arn"].split(":")

# removes the last 2 parts of login to be substituted by the info from getCallerIdentity
host_prefix = (login.split("/")[0..-3]).join("/")
aws_role_name = split_assumed_role[5].split("/")[1]
aws_account_id = response_hash["GetCallerIdentityResponse"]["GetCallerIdentityResult"]["Account"]
aws_user_id = response_hash["GetCallerIdentityResponse"]["GetCallerIdentityResult"]["UserId"]
host_to_match = "#{host_prefix}/#{aws_account_id}/#{aws_role_name}"

Rails.logger.debug("IAM Role authentication attempt by AWS user #{aws_user_id} with host to match = #{host_to_match}")

login.eql? host_to_match

end

def aws_signed_url
return 'https://sts.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15'
end

def response_from_signed_request(aws_headers)

Rails.logger.debug("Retrieving IAM identity")
RestClient.log = Rails.logger
begin
RestClient.get(aws_signed_url, headers = aws_headers)
rescue RestClient::ExceptionWithResponse => e
Rails.logger.error("Verification of IAM identity Exception #{e.to_s}")
raise InvalidAWSHeaders, e.to_s
end

end

end

end
end

7 changes: 7 additions & 0 deletions dev/files/authn-iam/entitlements.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
- !grant
role: !group conjur/authn-iam/prod/clients
member: !host /myapp/011915987442/MyApp

- !grant
role: !group conjur/authn-iam/prod/clients
member: !host /myapp/011915987442/lambda-function-role
40 changes: 40 additions & 0 deletions dev/files/authn-iam/myapp.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@

---
- !policy
id: myapp
body:
- &variables
- !variable database/username
- !variable database/password

# Create a group that will have permission to retrieve variables
- !group secrets-users

# Give the `secrets-users` group permission to retrieve variables
- !permit
role: !group secrets-users
privilege: [ read, execute ]
resource: *variables

# Create a layer to hold this application's hosts
- !layer

# Create a host using the namespace `aws` to identify this as an AWS resource.
# The host ID needs to match the AWS ARN of the role we wish to to authenticate.
- !host 011915987442/MyApp
- !host 011915987442/lambda-function-role

# Add our host into our layer
- !grant
role: !layer
member: !host 011915987442/MyApp

# Add our host into our layer
- !grant
role: !layer
member: !host 011915987442/lambda-function-role

# Give the host in our layer permission to retrieve variables
- !grant
member: !layer
role: !group secrets-users
12 changes: 12 additions & 0 deletions dev/files/authn-iam/policy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@

- !policy
id: conjur/authn-iam/prod
body:
- !webservice

- !group clients

- !permit
role: !group clients
privilege: [ read, authenticate ]
resource: !webservice
7 changes: 7 additions & 0 deletions dev/start
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Usage: start [options]
Drops you into the cucumber container.
You then manually start `conjurctl server` in another tab.
--authn-iam Starts with authn-iam/prod as authenticator
-h, --help Shows this help message.
EOF
exit
Expand All @@ -21,9 +22,11 @@ unset COMPOSE_PROJECT_NAME

# Determine which extra services should be loaded when working with authenticators
ENABLE_AUTHN_LDAP=false
ENABLE_AUTHN_IAM=false
ENABLE_ROTATORS=false
while true ; do
case "$1" in
--authn-iam ) ENABLE_AUTHN_IAM=true ; shift ;;
--authn-ldap ) ENABLE_AUTHN_LDAP=true ; shift ;;
--rotators ) ENABLE_ROTATORS=true ; shift ;;
-h | --help ) print_help ; shift ;;
Expand Down Expand Up @@ -61,6 +64,10 @@ fi
if [[ $ENABLE_ROTATORS = true ]]; then
services="$services testdb cucumber"
fi
if [[ $ENABLE_AUTHN_LDAP = true ]]; then
env_args="$env_args -e CONJUR_AUTHENTICATORS=authn-iam/prod"
docker-compose exec conjur conjurctl policy load cucumber /src/conjur-server/dev/files/authn-iam/policy.yml
fi

docker-compose up -d --no-deps $services

Expand Down
106 changes: 106 additions & 0 deletions spec/app/domain/authentication/authn_iam/authenticator_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
require 'spec_helper'

RSpec.describe Authentication::AuthnIam::Authenticator do

def expired_aws_headers
"{\"host\":\"sts.amazonaws.com\",\"x-amz-date\":\"20180620T025910Z\","\
"\"x-amz-security-token\":\"FQoDYXdzEPv//////////wEaDHwvkDqh5pHmZNe5hSK3AzevmnHjzweG6m1in"\
"/CQ8NB7PCY0nTtWsCXLU5FsHmOoXs6KgVOu8ucghebak4b/iaDCpSprH3GPjLcNatywkUEQqX8rQKy2DoKMy7ZMHNT1ivhEn "\
"vE3HR0GPkkGGWYhLTTrQDdI5fBcb3yJ /TyrcmUuBTKXwQJmvcnDe505SPpuSZm7tdrDX5SpItMngqGcrRhCjuprpk5nPVwSQ"\
"q6usp7hJYPmu/6u9eVP3rQ TFPldhRvRxu5rcssURdrIwbjMugZQff/8XERxyxPrTkJQekcqMvvV6gexDZcBOS1JtIsKfJEXU"\
"mK3kwV4liQsUevxyanWMc4jT0tiBkDj2 nvXUFt6dejppdTTRdEtBXg5xZUrGDCQDUU9eBgydoTLGav9rWiM7bWtpP4A1m0E9"\
"LoX47FScSDkqk0Hy6Dr9jzhb4HOodlgaldTs8BNlgN9xXgACdacdPqnhaYLCgAWsaUZPKuZmdyH96F59rcrVscf456ivXTrXp"\
"t6pL1ZQyRCc04hkovErvv1L2CwEaGAc k0bvbq0pbzTftTh7 9xY3pFxbL AALoR0t2/CfhyomvoG72Cl/nvAo7 "\
"m2QU\",\"x-amz-content-sha256\":\"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b"\
"855\",\"authorization\":\"AWS4-HMAC-SHA256 Credential=ASIAJJTVXJS5KDKXKNPQ/20180620/us-east-1/sts/aw"\
"s4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-security-token, Signature=230fa"\
"38a232969747b77e82f6c845f63941ebde89eb2cc20ed1c6f2dbabc92b6\"}"
end

def valid_response
double('HTTPResponse',
code: 200,
body: %(
<GetCallerIdentityResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
<GetCallerIdentityResult>
<Arn>arn:aws:sts::011915987442:assumed-role/MyApp/i-0a5702a5a078e1a00</Arn>
<UserId>AROAIYXQMEFIAVEOFMW5Y:i-0a5702a5a078e1a00</UserId>
<Account>011915987442</Account>
</GetCallerIdentityResult>
<ResponseMetadata>
<RequestId>f555066a-7417-11e8-8ded-8daed431985e</RequestId>
</ResponseMetadata>
</GetCallerIdentityResponse>
)
)
end

def invalid_response
double('HTTPResponse',
code: 404,
body: "Error"
)
end

def valid_login
"host/myapp/011915987442/MyApp"
end

def invalid_login
"host/myapp/InvalidAccount/InvalidRole"
end

let (:authenticator_instance) do
Authentication::AuthnIam::Authenticator.new(env:[])
end

it "valid? with expired AWS headers" do
subject = authenticator_instance
parameters = double('AuthenticationParameters', password: expired_aws_headers)
expect{subject.valid?(parameters)}.to(
raise_error(Authentication::AuthnIam::Authenticator::InvalidAWSHeaders)
)
end

it "validates identity_hash with valid response" do
subject = authenticator_instance
expect { subject.identity_hash(valid_response) }.to_not raise_error

expect(subject.identity_hash(valid_response)).to have_key("GetCallerIdentityResponse")

expected = {
"GetCallerIdentityResponse" => a_hash_including(
"GetCallerIdentityResult" => a_hash_including(
"Arn" => "arn:aws:sts::011915987442:assumed-role/MyApp/i-0a5702a5a078e1a00",
"UserId" => anything,
"Account" => anything
)
)
}

expect(subject.identity_hash(valid_response)).to include(expected)
expect(subject.identity_hash(valid_response))

end

it "validates identity_hash with invalid response" do
subject = authenticator_instance
expect(subject.identity_hash(invalid_response)).to eq(false)
end

it "matches valid login to AWS IAM role (based on AWS response)" do
subject = authenticator_instance
identity_hash = subject.identity_hash(valid_response)

expect(subject.iam_role_matches?(valid_login, identity_hash)).to eq(true)
expect(subject.iam_role_matches?(invalid_login, identity_hash)).to eq(false)
end

it "fails invalid login with AWS IAM role (based on AWS response)" do
subject = authenticator_instance
identity_hash = subject.identity_hash(valid_response)

expect(subject.iam_role_matches?(invalid_login, identity_hash)).to eq(false)
end

end

0 comments on commit f3f636f

Please sign in to comment.