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

Add new aws-iam authentication backend #1962

Closed
wants to merge 3 commits into from

Conversation

joelthompson
Copy link
Contributor

The aws-iam authentication backend is able to validate arbitrary
AWS IAM principals against AWS. It does this by receiving a signed AWS
STS GetCallerIdentity request and forwarding that on to AWS. AWS will
validate the request and return the principal, which allows Vault to
then map the caller to a set of policies.

Many thanks to @copumpkin for his feedback in reviewing this thus far!

The aws-iam authentication backend is able to validate arbitrary
AWS IAM principals against AWS. It does this by receiving a signed AWS
STS GetCallerIdentity request and forwarding that on to AWS. AWS will
validate the request and return the principal, which allows Vault to
then map the caller to a set of policies.
$ vault write auth/aws-iam/role/dev-role bound_iam_principal=arn:aws:iam::123456789012:role/my_role policies=prod,dev max_ttl=500h
```

#### Configure a required X-Vault-Server Header (recommended)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd love some feedback. It would be great to require this header be set, but I'm not sure what the right default is. redirect_addr seems like a promising start, but I'm not sure if there's a good way to access that configuration variable from within an authentication backend. Any thoughts?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't particularly want it to be required for all as it adds extra burden without benefit on dev setups or single-Vault shops. An administrator can easily make it required.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK.

@joelthompson
Copy link
Contributor Author

Also, this implements the scheme in #948

@jefferai jefferai added this to the next milestone Oct 4, 2016
@mfischer-zd
Copy link
Contributor

Yes, please.

@fieldju
Copy link

fieldju commented Oct 25, 2016

@joelthompson when did AWS implement GetCallerIdentity I remember talking about it in: #948.

@joelthompson
Copy link
Contributor Author

@fieldju -- not 100% sure. Support for it was committed in boto/botocore@1ddefda on April 5, so likely sometime around then.

@jefferai jefferai modified the milestones: near-term, 0.6.4 Oct 26, 2016
@fieldju
Copy link

fieldju commented Dec 1, 2016

Yeah, im pretty stocked for this. Can anyone here speak to when 0.6.4 might get released?

@jefferai
Copy link
Member

jefferai commented Dec 2, 2016

It's tentatively scheduled for some time in January.

@mfischer-zd
Copy link
Contributor

Why not merge now?

@jefferai
Copy link
Member

jefferai commented Dec 2, 2016

Because we're currently at the end of the 0.6.3 timeframe. 0.6.4 comes after that.

var err error

access_key, ok := m["aws_access_key_id"]
if ok {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use helper/awsutil to generate the credential chain as this guarantees similar behavior throughout Vault. When used within the Vault server, the http client should use cleanhttp as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done (on helper/awsutil)

configEntry = &clientConfig{}
}

endpointStr := data.Get("endpoint").(string)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should use GetOk to know whether or not blank values are purposeful or not, and then key what to do based on the OK value and the operation type. See the token store roles create/update function in vault/token_store.go.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, thanks. Done.


// In the future, might consider supporting GET
if method != "POST" {
return logical.ErrorResponse("Invalid method; currently only 'POST' is supported"), nil
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/Invalid/invalid

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done


// TODO: There are two potentially valid cases we're not yet supporting that would
// necessitate this check being changed. First, if we support GET requests.
// Second if we support presigned POST requests
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two stupid questions here. First, what are presigned POST requests in this context, since the client is submitting headers to Vault, and second, if those were to be supported, would it require breaking changes?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Definitely not stupid questions, you're getting into some of the subtle inner workings of the AWS signature protocol!

A "presigned" request, in AWS parlance, is where the client embeds the signature information inside of the URI query string, rather than as separate HTTP headers. The idea is that this "presigned" request URI is handed out as a time-limited means of granting access to a particular resource (such as an object in an S3 bucket), which is a pretty similar concept to what we're doing here. Normally, this is done with GET requests, but there's no reason in principle that you couldn't do this with POST requests. In fact, in boto3 at least, if you call the generate_presigned_url method for GetCallerIdentity, it'll actually give you a presigned POST request because deep within the boto3 model, it thinks that GetCallerIdentity should always be a POST request. (See boto/boto3#756 for an example of this.)

And I'm pretty sure that supporting it wouldn't require breaking changes. Supporting GET would just involve removing that the non-empty body check and replacing it with making sure there's an action in the URI query string, and supporting presigned requests (whether GET or POST) would require teaching Vault to look in the query string to ensure that the X-Vault-Server (or whatever header name is decided upon) is included in the signed requests.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK. So, seems like this is good for now!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool, though in thinking more, I think I should to update the docs to be explicit about this choice of not supporting presigned GET or POST requests (leaving this comment as a reminder to myself to do the docs update).

if err != nil {
return logical.ErrorResponse("headers is invalid base64"), nil
}
var headers map[string]string
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there ever a reason when a header being submitted might need to be duplicated? If so the client should send map[string][]string, IOW it should use http.Header.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to? No. RFC 2616 is pretty adamant about that (yeah, I had to look that one up myself when working on this):

Multiple message-header fields with the same field-name MAY be present in a message if and only if the entire field-value for that header field is defined as a comma-separated list [i.e., #(values)]. It MUST be possible to combine the multiple header fields into one "field-name: field-value" pair, without changing the semantics of the message, by appending each subsequent field-value to the first, each separated by a comma.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep -- I know about how you can do that, but there are definitely caveats. That's why I was asking above about what the Python client is doing there, if it is just replacing, or if it is squishing everything with commas.

In a general sense it feels to me like if we are sending headers over the API and want to be mildly standardized about it if we end up doing the same thing elsewhere in Vault (as any long-time user knows it's been a battle to get things more standardized) we should use the data structures that allow the user to more exactly specify what they should look like without making assumptions about whether they can be string-listed and so on.

But, feel free to convince me otherwise :-)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The more I think about this, the more I think you're probably right. Vault is in go, the code should use go idioms as much as possible. Give me a few days to rewrite this using the go-standard format and also see what the python client looks like and I'll either have an updated PR or some actual code showing why this would be uglier :)

request := buildHttpRequest(method, endpoint, parsedUrl, body, headers)
client := &http.Client{}
response, err := client.Do(request)
defer response.Body.Close()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You need to check that the response isn't nil before doing this defer.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

svc := sts.New(stsSession)
stsRequest, _ := svc.GetCallerIdentityRequest(params)
if headerValue != "" {
stsRequest.HTTPRequest.Header.Add("X-Vault-Server", headerValue)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should use the const value you declare later.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

},
"policies": {
Type: framework.TypeString,
Default: "default",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't need "default" here as it gets added independently of the backend-defined policies.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cool, thanks. Done.

func (b *backend) pathRoleRead(
req *logical.Request, data *framework.FieldData) (*logical.Response, error) {

roleEntry, err := b.lockedAWSRole(req.Storage, strings.ToLower(data.Get("role").(string)))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should check that role isn't empty.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done (via nonLockedAWSRole)

$ vault write auth/aws-iam/role/dev-role bound_iam_principal=arn:aws:iam::123456789012:role/my_role policies=prod,dev max_ttl=500h
```

#### Configure a required X-Vault-Server Header (recommended)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't particularly want it to be required for all as it adds extra burden without benefit on dev setups or single-Vault shops. An administrator can easily make it required.

Copy link
Contributor Author

@joelthompson joelthompson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the detailed feedback!

var err error

access_key, ok := m["aws_access_key_id"]
if ok {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done (on helper/awsutil)

svc := sts.New(stsSession)
stsRequest, _ := svc.GetCallerIdentityRequest(params)
if headerValue != "" {
stsRequest.HTTPRequest.Header.Add("X-Vault-Server", headerValue)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

stsRequest.HTTPRequest.Header.Add("X-Vault-Server", headerValue)
}
stsRequest.Sign()
headersJson, err := json.Marshal(transformHeaders(stsRequest.HTTPRequest.Header))
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand your question, sorry.

Are you asking why I'm JSON-encoding the request headers? It's because I need to encode the headers of what the STS request would be and send them to the aws-iam backend so that the backend can replay those headers (versus the headers on the request to the Vault server itself).

Are you asking why I'm calling the transformHeaders method? It's a bit arbitrary, but I found that Python and Go HTTP clients treat the headers a bit differently, in that Go maps a string to an array of strings, while Python maps a string to a single string. My original test client was Python, so I wrote the backend to support that, and then when I wrote the Go client, I discovered the discrepancy and felt that keeping the Python representation would lead to simpler code without a loss of generality, so I decided to keep it.

Are you asking something else I'm not understanding?

configEntry = &clientConfig{}
}

endpointStr := data.Get("endpoint").(string)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, thanks. Done.


// In the future, might consider supporting GET
if method != "POST" {
return logical.ErrorResponse("Invalid method; currently only 'POST' is supported"), nil
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done


## Comparison with AWS-EC2 Authentication Backend

The AWS-IAM and AWS-EC2 authentication backends serve similar purposes. Both
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I was honestly trying to make it as unbiased as possible. I guess I'm just not seeing the "significant drawbacks" that you're seeing. Can you provide some more details about what you think is missing so I can make this more fair and balanced?


## Authentication Workflow

AWS has introduced the [`sts:GetCallerIdentity`](http://docs.aws.amazon.com/STS/latest/APIReference/API_GetCallerIdentity.html)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've reworded a little, let me know if this reads better.


Each signed AWS request includes the current timestamp to mitigate the risk of
replay attacks. In addition, Vault allows you to require an additional header,
`X-Vault-Server`, to be present to mitigate against different types of replay
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair enough. How about X-Vault-AWSIAM-Server-ID?

$ vault write auth/aws-iam/role/dev-role bound_iam_principal=arn:aws:iam::123456789012:role/my_role policies=prod,dev max_ttl=500h
```

#### Configure a required X-Vault-Server Header (recommended)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK.

return nil, fmt.Errorf("role entry not found")
}

if roleEntry.BoundIamPrincipal != canonicalArn {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good question. The only thing Vault sees, by design, is the access key and the session token (if it's an STS token). There's no good way, in AWS, to determine which IAM principal an access key belongs to. The only real way of doing that is by calling the GetCallerIdentity method, which requires the secret key in order to sign the request, and by design, Vault never sees the secret key. Further, as @copumpkin states, the request itself will expire within 15 minutes, so it's not feasible to really store it.

It is possible, as @copumpkin states, to add a date constraint to an IAM role policy to cut off access for all session tokens that were issued before a certain date. However, as best as I can tell, GetCallerIdentity doesn't actually do any authorization checks, and so this trick won't work for denying future GetCallerIdentity requests.

If a Vault admin is concerned by this, then I think the right solution is some combination of low token TTL (so forcing a periodic renewal) and/or low max token TTL or just disabling renewal altogether (so forcing a revalidation that the client has valid IAM creds).

@jefferai -- in your most recent comment, you're quoting a section about GetSessionToken and not GetCallerIdentity. And, on your confusion, in my experience, I think that's inconsistent documentation standards. In this context, I think that the "account credentials" is credentials for the root user (which should almost never be used), IAM users are long-lived, static creds that we're desperately trying to avoid building a dependency on, and then you have short-lived creds that we like because they expire automatically. So, yes, the user could submit a new signed request with a renewal, but I think that breaks the paradigm of a "renewal" (which is just the client presenting the original token, without additional authenticating information, and asking for the lifetime to be extended, right?). I think that, if you want the user to have to present a new signed request, the easiest and best way to do that would be to just set a low TTL and max TTL (e.g., disallow renewals) and force the client to get a new authentication token.

This would probably be good to capture in the docs. @jefferai -- let me know if this makes sense to you and I'll add it.

@vishalnayak vishalnayak self-assigned this Dec 14, 2016
@jefferai jefferai modified the milestones: 0.6.4, 0.6.5 Dec 16, 2016
Also improving the documentation
@joelthompson
Copy link
Contributor Author

OK, I think I've addressed all the outstanding comments, please let me know if I've missed something, or if you've got more feedback!

@jantman
Copy link
Contributor

jantman commented Dec 21, 2016

I can't speak to reviewing the code right now - most of it is over my head - but I will say that if (as seems to be the case) this provides a solution to the first secret problem in both Lambda and ECS, I can't overstate how important and useful this is.

@jefferai
Copy link
Member

It depends if IAM roles is enough granularity. That is likely to depend on use case (or AWS architecture) and security policy since you can't map to particular instantiations. We're still in contact with the AWS team on adding more useful primitives so that tasks can be uniquely identified.

@jefferai jefferai modified the milestones: 0.6.5, 0.7.0 Jan 26, 2017
@fieldju
Copy link

fieldju commented Feb 9, 2017

@jefferai

Hello, I was just curious about the status of this PR, I noticed that you moved it from 0.6.5 -> 0.7.0.
Are you still trying to work with AWS to see if there is a better way, are you satisfied with the flow that this backend would provide?
Is AWS adding an API that would support 3rd parties ability to validate identity in a first class way?

Cheers,

@fieldju

@joelthompson
Copy link
Contributor Author

@jefferai and I spoke offline about incorporating the matching of EC2 instances, so I've got most of that done, and will open a new PR when ready.

@jefferai jefferai modified the milestones: 0.7.0, 0.7.1 Feb 17, 2017
@mfischer-zd
Copy link
Contributor

Hi @joelthompson @jefferai - it looks like this might not make 0.7.0. Is that so?

@jefferai
Copy link
Member

jefferai commented Mar 2, 2017

Correct, but hopefully it'll make it for 0.7.1.

@joelthompson
Copy link
Contributor Author

Given discussions with @jefferai I felt like creating a brand new pull request would be a cleaner way of implementing the unified auth backend, so I've proposed that in #2441

@joelthompson
Copy link
Contributor Author

Also, everyone, sorry it took so long, but I've been travelling quite a bit recently and hadn't had time to implement the changes I needed to make until now.

@jefferai
Copy link
Member

Closing in favor of #2441

@jefferai jefferai closed this Mar 26, 2017
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants