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

Add support for external-id and session-name #430

Merged
merged 3 commits into from
Nov 11, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
stefansedich marked this conversation as resolved.
Show resolved Hide resolved
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