Skip to content
This repository has been archived by the owner on Mar 5, 2024. It is now read-only.

Commit

Permalink
Add support for external-id and session-name (#430)
Browse files Browse the repository at this point in the history
* Add support for external-id and session-name when assuming a pods role using STS via annotations
  • Loading branch information
stefansedich authored Nov 11, 2020
1 parent 2d74a51 commit 67dea0c
Show file tree
Hide file tree
Showing 21 changed files with 492 additions and 102 deletions.
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,31 @@ metadata:
iam.amazonaws.com/role: reportingdb-reader
```
You can control the session name used when assuming the role via an annotation added to the `Pod`, which may be used to further identify the session. For example:

```yaml
kind: Pod
metadata:
name: foo
namespace: session-name-example
annotations:
iam.amazonaws.com/role: reportingdb-reader
iam.amazonaws.com/session-name: my-session-name
```

You can also control the external id used when assuming the role via an annotation added to the `Pod`, which
maybe used to avoid [confused deputy scenarios in cross-organisation role assumption](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-user_externalid.html). For example:

```yaml
kind: Pod
metadata:
name: foo
namespace: external-id-example
annotations:
iam.amazonaws.com/role: reportingdb-reader
iam.amazonaws.com/external-id: dac7ad46-acab-4ec3-a78e-f3962ecf45d7
```

Further, all namespaces must also have an annotation with a regular expression expressing which roles are permitted to be assumed within that namespace. **Without the namespace annotation the pod will be unable to assume any roles.**

```yaml
Expand Down
15 changes: 12 additions & 3 deletions pkg/aws/sts/arn_resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,31 +22,40 @@ type Resolver struct {
prefix string
}

type ResolvedRole struct {
Name string
ARN string
}

// DefaultResolver will add the prefix to any roles which
// don't start with arn:
func DefaultResolver(prefix string) *Resolver {
return &Resolver{prefix: prefix}
}

// Resolve converts from a role string into the absolute role arn.
func (r *Resolver) Resolve(role string) (*RoleIdentity, error) {
func (r *Resolver) Resolve(role string) (*ResolvedRole, error) {
if role == "" {
return nil, fmt.Errorf("role can't be empty")
}

if strings.HasPrefix(role, "arn:") {
return &RoleIdentity{ARN: role, Role: roleFromArn(role)}, nil
return &ResolvedRole{ARN: role, Name: roleFromArn(role)}, nil
}

if strings.HasPrefix(role, "/") {
role = strings.TrimPrefix(role, "/")
}

return &RoleIdentity{ARN: fmt.Sprintf("%s%s", r.prefix, role), Role: role}, nil
return &ResolvedRole{ARN: fmt.Sprintf("%s%s", r.prefix, role), Name: role}, nil
}

// arn:aws:iam::account-id:role/role-name-with-path
func roleFromArn(arn string) string {
splits := strings.SplitAfterN(arn, ":", 6)
return strings.TrimPrefix(splits[5], "role/")
}

func (i *ResolvedRole) Equals(other *ResolvedRole) bool {
return *i == *other
}
44 changes: 22 additions & 22 deletions pkg/aws/sts/arn_resolver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import (
"testing"
)

func TestRoleIdentityEquality(t *testing.T) {
func TestResolvedRoleEquality(t *testing.T) {
resolver := DefaultResolver("arn:aws:iam::account-id:role/")
i1, _ := resolver.Resolve("foo")
i2, _ := resolver.Resolve("foo")
Expand All @@ -34,10 +34,10 @@ func TestRoleIdentityEquality(t *testing.T) {

func TestAddsPrefix(t *testing.T) {
resolver := DefaultResolver("arn:aws:iam::account-id:role/")
identity, _ := resolver.Resolve("myrole")
resolvedRole, _ := resolver.Resolve("myrole")

if identity.ARN != "arn:aws:iam::account-id:role/myrole" {
t.Error("unexpected role, was:", identity.ARN)
if resolvedRole.ARN != "arn:aws:iam::account-id:role/myrole" {
t.Error("unexpected role, was:", resolvedRole.ARN)
}
}

Expand All @@ -52,47 +52,47 @@ func TestReturnsErrorForEmptyRole(t *testing.T) {

func TestAddsPrefixWithRoleBeginningWithSlash(t *testing.T) {
resolver := DefaultResolver("arn:aws:iam::account-id:role/")
identity, _ := resolver.Resolve("/myrole")
resolvedRole, _ := resolver.Resolve("/myrole")

if identity.ARN != "arn:aws:iam::account-id:role/myrole" {
t.Error("unexpected role, was:", identity.ARN)
if resolvedRole.ARN != "arn:aws:iam::account-id:role/myrole" {
t.Error("unexpected role, was:", resolvedRole.ARN)
}

if identity.Role != "myrole" {
t.Error("unexpected role, was", identity.Role)
if resolvedRole.Name != "myrole" {
t.Error("unexpected role, was", resolvedRole.Name)
}
}
func TestAddsPrefixWithRoleBeginningWithPathWithoutSlash(t *testing.T) {
resolver := DefaultResolver("arn:aws:iam::account-id:role/")
identity, _ := resolver.Resolve("kiam/myrole")
resolvedRole, _ := resolver.Resolve("kiam/myrole")

if identity.ARN != "arn:aws:iam::account-id:role/kiam/myrole" {
t.Error("unexpected role, was:", identity.ARN)
if resolvedRole.ARN != "arn:aws:iam::account-id:role/kiam/myrole" {
t.Error("unexpected role, was:", resolvedRole.ARN)
}

if identity.Role != "kiam/myrole" {
t.Error("unexpected role", identity.Role)
if resolvedRole.Name != "kiam/myrole" {
t.Error("unexpected role", resolvedRole.Name)
}
}
func TestAddsPrefixWithRoleBeginningWithSlashPath(t *testing.T) {
resolver := DefaultResolver("arn:aws:iam::account-id:role/")
identity, _ := resolver.Resolve("/kiam/myrole")
resolvedRole, _ := resolver.Resolve("/kiam/myrole")

if identity.ARN != "arn:aws:iam::account-id:role/kiam/myrole" {
t.Error("unexpected role, was:", identity.ARN)
if resolvedRole.ARN != "arn:aws:iam::account-id:role/kiam/myrole" {
t.Error("unexpected role, was:", resolvedRole.ARN)
}
}

func TestUsesAbsoluteARN(t *testing.T) {
resolver := DefaultResolver("arn:aws:iam::account-id:role/")
identity, _ := resolver.Resolve("arn:aws:iam::some-other-account:role/path-prefix/another-role")
resolvedRole, _ := resolver.Resolve("arn:aws:iam::some-other-account:role/path-prefix/another-role")

if identity.ARN != "arn:aws:iam::some-other-account:role/path-prefix/another-role" {
t.Error("unexpected role, was:", identity.ARN)
if resolvedRole.ARN != "arn:aws:iam::some-other-account:role/path-prefix/another-role" {
t.Error("unexpected role, was:", resolvedRole.ARN)
}

if identity.Role != "path-prefix/another-role" {
t.Error("expected role to be set, was", identity.Role)
if resolvedRole.Name != "path-prefix/another-role" {
t.Error("expected role to be set, was", resolvedRole.Name)
}
}

Expand Down
45 changes: 33 additions & 12 deletions pkg/aws/sts/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package sts
import (
"context"
"fmt"
"regexp"
"time"

"github.com/patrickmn/go-cache"
Expand All @@ -34,11 +35,6 @@ type credentialsCache struct {
gateway STSGateway
}

type RoleIdentity struct {
Role string
ARN string // Amazon Resource Name for the Role
}

type CachedCredentials struct {
Identity *RoleIdentity
Credentials *Credentials
Expand All @@ -56,7 +52,7 @@ func DefaultCache(
) *credentialsCache {
c := &credentialsCache{
expiring: make(chan *CachedCredentials, 1),
sessionName: fmt.Sprintf("kiam-%s", sessionName),
sessionName: sessionName,
sessionDuration: sessionDuration,
cacheTTL: sessionDuration - sessionRefresh,
gateway: gateway,
Expand Down Expand Up @@ -95,7 +91,7 @@ func (c *credentialsCache) Expiring() chan *CachedCredentials {
// CredentialsForRole looks for cached credentials or requests them from the STSGateway. Requested credentials
// must have their ARN set.
func (c *credentialsCache) CredentialsForRole(ctx context.Context, identity *RoleIdentity) (*Credentials, error) {
logger := log.WithFields(log.Fields{"pod.iam.role": identity.Role, "pod.iam.roleArn": identity.ARN})
logger := log.WithFields(identity.LogFields())
item, found := c.cache.Get(identity.String())

if found {
Expand All @@ -117,7 +113,16 @@ func (c *credentialsCache) CredentialsForRole(ctx context.Context, identity *Rol
cacheMiss.Inc()

issue := func() (interface{}, error) {
credentials, err := c.gateway.Issue(ctx, identity.ARN, c.sessionName, c.sessionDuration)
sessionName := c.getSessionName(identity)

stsIssueRequest := &STSIssueRequest{
RoleARN: identity.Role.ARN,
SessionName: sessionName,
ExternalID: identity.ExternalID,
SessionDuration: c.sessionDuration,
}

credentials, err := c.gateway.Issue(ctx, stsIssueRequest)
if err != nil {
errorIssuing.Inc()
logger.Errorf("error requesting credentials: %s", err.Error())
Expand Down Expand Up @@ -146,10 +151,26 @@ func (c *credentialsCache) CredentialsForRole(ctx context.Context, identity *Rol
return cachedCreds.Credentials, nil
}

func (i *RoleIdentity) String() string {
return i.ARN
func (c *credentialsCache) getSessionName(identity *RoleIdentity) string {
sessionName := c.sessionName

if identity.SessionName != "" {
sessionName = identity.SessionName
}

sessionName = fmt.Sprintf("kiam-%s", sessionName)
return sanitizeSessionName(sessionName)
}

func (i *RoleIdentity) Equals(other *RoleIdentity) bool {
return *i == *other
// Ensure the session name meets length requirements and
// also coercce any character that doens't meet the pattern
// requirements to a hyhen so that we ensure a valid session name.
func sanitizeSessionName(sessionName string) string {
sanitize := regexp.MustCompile(`([^\w+=,.@-])`)

if len(sessionName) > 64 {
sessionName = sessionName[0:63]
}

return sanitize.ReplaceAllString(sessionName, "-")
}
68 changes: 61 additions & 7 deletions pkg/aws/sts/credentials_cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,26 @@ package sts

import (
"context"
"github.com/prometheus/client_golang/prometheus/testutil"
"testing"
"time"

"github.com/prometheus/client_golang/prometheus/testutil"
)

type stubGateway struct {
c *Credentials
issueCount int
requestedRole string
c *Credentials
issueCount int
requestedRole string
requestedSessionName string
requestedExternalID string
}

func (s *stubGateway) Issue(ctx context.Context, roleARN, sessionName string, expiry time.Duration) (*Credentials, error) {
func (s *stubGateway) Issue(ctx context.Context, request *STSIssueRequest) (*Credentials, error) {
s.issueCount = s.issueCount + 1
s.requestedRole = roleARN
s.requestedRole = request.RoleARN
s.requestedSessionName = request.SessionName
s.requestedExternalID = request.ExternalID

return s.c, nil
}

Expand All @@ -37,7 +43,7 @@ func TestRequestsCredentialsFromGatewayWithEmptyCache(t *testing.T) {
cache := DefaultCache(stubGateway, "session", 15*time.Minute, 5*time.Minute)
ctx := context.Background()

credentialsIdentity := &RoleIdentity{Role: "role", ARN: "arn:account:role"}
credentialsIdentity := &RoleIdentity{Role: ResolvedRole{Name: "role", ARN: "arn:account:role"}}
creds, _ := cache.CredentialsForRole(ctx, credentialsIdentity)
if creds.Code != "foo" {
t.Error("didnt return expected credentials code, was", creds.Code)
Expand All @@ -55,3 +61,51 @@ func TestRequestsCredentialsFromGatewayWithEmptyCache(t *testing.T) {
t.Error("unexpected role, was:", stubGateway.requestedRole)
}
}

func TestRequestsCredentialsWithSessionName(t *testing.T) {
var tests = []struct {
name string
sessionName string
expectedSessionName string
}{
{"Default", "testing", "kiam-testing"},
{"InvalidCharsReplacedWithHyphen", "testing@#&-test%", "kiam-testing@---test-"},
{"LongNameLimitedTo64Chars", "Unsplvku4rP9A71Zb5DUQtKviVKSENh0GlKxVRPXGvfDyXXXy8OGqTVfc05DCAhKT9oHXU", "kiam-Unsplvku4rP9A71Zb5DUQtKviVKSENh0GlKxVRPXGvfDyXXXy8OGqTVfc0"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
stubGateway := &stubGateway{c: &Credentials{Code: "foo"}}
cache := DefaultCache(stubGateway, "session", 15*time.Minute, 5*time.Minute)
ctx := context.Background()

credentialsIdentity := &RoleIdentity{
Role: ResolvedRole{Name: "role", ARN: "arn:account:role"},
SessionName: tt.sessionName,
}

_, _ = cache.CredentialsForRole(ctx, credentialsIdentity)

if stubGateway.requestedSessionName != tt.expectedSessionName {
t.Error("unexpected session-name, was:", stubGateway.requestedSessionName)
}
})
}
}

func TestRequestsCredentialsWithExternalID(t *testing.T) {
stubGateway := &stubGateway{c: &Credentials{Code: "foo"}}
cache := DefaultCache(stubGateway, "session", 15*time.Minute, 5*time.Minute)
ctx := context.Background()

credentialsIdentity := &RoleIdentity{
Role: ResolvedRole{Name: "role", ARN: "arn:account:role"},
ExternalID: "123456",
}

_, _ = cache.CredentialsForRole(ctx, credentialsIdentity)

if stubGateway.requestedExternalID != "123456" {
t.Error("unexpected external-id, was:", stubGateway.requestedExternalID)
}
}
22 changes: 17 additions & 5 deletions pkg/aws/sts/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,15 @@ import (
"github.com/prometheus/client_golang/prometheus"
)

type STSIssueRequest struct {
RoleARN string
SessionName string
ExternalID string
SessionDuration time.Duration
}

type STSGateway interface {
Issue(ctx context.Context, role, session string, expiry time.Duration) (*Credentials, error)
Issue(ctx context.Context, request *STSIssueRequest) (*Credentials, error)
}

type DefaultSTSGateway struct {
Expand All @@ -35,7 +42,7 @@ func DefaultGateway(config *aws.Config) (*DefaultSTSGateway, error) {
return &DefaultSTSGateway{session: session.Must(session.NewSession(config))}, nil
}

func (g *DefaultSTSGateway) Issue(ctx context.Context, roleARN, sessionName string, expiry time.Duration) (*Credentials, error) {
func (g *DefaultSTSGateway) Issue(ctx context.Context, request *STSIssueRequest) (*Credentials, error) {
timer := prometheus.NewTimer(assumeRole)
defer timer.ObserveDuration()

Expand All @@ -44,10 +51,15 @@ func (g *DefaultSTSGateway) Issue(ctx context.Context, roleARN, sessionName stri

svc := sts.New(g.session)
in := &sts.AssumeRoleInput{
DurationSeconds: aws.Int64(int64(expiry.Seconds())),
RoleArn: aws.String(roleARN),
RoleSessionName: aws.String(sessionName),
DurationSeconds: aws.Int64(int64(request.SessionDuration.Seconds())),
RoleArn: aws.String(request.RoleARN),
RoleSessionName: aws.String(request.SessionName),
}

if request.ExternalID != "" {
in.ExternalId = aws.String(request.ExternalID)
}

resp, err := svc.AssumeRoleWithContext(ctx, in)
if err != nil {
return nil, err
Expand Down
2 changes: 1 addition & 1 deletion pkg/aws/sts/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,5 @@ type CredentialsCache interface {

// ARNResolver encapsulates resolution of roles into ARNs.
type ARNResolver interface {
Resolve(role string) (*RoleIdentity, error)
Resolve(role string) (*ResolvedRole, error)
}
Loading

0 comments on commit 67dea0c

Please sign in to comment.