Skip to content

Commit

Permalink
plugins/rest/aws: Add new credential provider for AWS credential files
Browse files Browse the repository at this point in the history
Earlier users could provide sensitive values such as AWS
secret keys using environment variables. This change adds
a new AWS credential provider which reads the credential file
to fetch credentials for a named profile. If no profile is
provided, the "default" profile is used. OPA reads the
credentials from the file on each request and uses them
for authentication.

Fixes: open-policy-agent#2786

Signed-off-by: Ashutosh Narkar <anarkar4387@gmail.com>
Co-authored-by: Stephan Renatus <stephan.renatus@gmail.com>
  • Loading branch information
ashutosh-narkar and srenatus committed Dec 1, 2021
1 parent d94685f commit 7d73d52
Show file tree
Hide file tree
Showing 4 changed files with 351 additions and 5 deletions.
13 changes: 13 additions & 0 deletions docs/content/configuration.md
Expand Up @@ -417,6 +417,19 @@ Please note that if you are using temporary IAM credentials (e.g. assumed IAM ro
| --- | --- | --- | --- |
| `services[_].credentials.s3_signing.environment_credentials` | `{}` | Yes | Enables AWS signing using environment variables to source the configuration and credentials |


##### Using Named Profile Credentials
If specifying `profile_credentials`, OPA will expect to find the `access key id`, `secret access key` and
`session token` from the [named profiles](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-profiles.html)
stored in the [credentials](https://docs.aws.amazon.com/sdkref/latest/guide/file-format.html) file on disk. On each
request OPA will re-read the credentials from the file and use them for authentication.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `services[_].credentials.s3_signing.profile_credentials.path` | `string` | No | The path to the shared credentials file. If empty, OPA will look for the `AWS_SHARED_CREDENTIALS_FILE` env variable. If the variable is not set, the path defaults to the current user's home directory. `~/.aws/credentials` (Linux & Mac) or `%USERPROFILE%\.aws\credentials` (Windows) |
| `services[_].credentials.s3_signing.profile_credentials.profile` | `string` | No | AWS Profile to extract credentials from the credentials file. If empty, OPA will look for the `AWS_PROFILE` env variable. If the variable is not set, the `default` profile will be used |
| `services[_].credentials.s3_signing.metadata_credentials.aws_region` | `string` | No | The AWS region to use for the AWS signing service credential method. If unset, the `AWS_REGION` environment variable must be set |

##### Using EC2 Metadata Credentials
If specifying `metadata_credentials`, OPA will use the AWS metadata services for [EC2](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html)
or [ECS](https://docs.aws.amazon.com/AmazonECS/latest/userguide/task-iam-roles.html)
Expand Down
107 changes: 106 additions & 1 deletion plugins/rest/aws.go
Expand Up @@ -16,10 +16,13 @@ import (
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"time"

"github.com/go-ini/ini"

"github.com/open-policy-agent/opa/logging"
)

Expand All @@ -46,6 +49,13 @@ const (
awsRegionEnvVar = "AWS_REGION"
awsRoleArnEnvVar = "AWS_ROLE_ARN"
awsWebIdentityTokenFileEnvVar = "AWS_WEB_IDENTITY_TOKEN_FILE"
awsCredentialsFileEnvVar = "AWS_SHARED_CREDENTIALS_FILE"
awsProfileEnvVar = "AWS_PROFILE"

// ref. https://docs.aws.amazon.com/sdkref/latest/guide/settings-global.html
accessKeyGlobalSetting = "aws_access_key_id"
secretKeyGlobalSetting = "aws_secret_access_key"
securityTokenGlobalSetting = "aws_session_token"
)

// Headers that may be mutated before reaching an aws service (eg by a proxy) should be added here to omit them from
Expand Down Expand Up @@ -89,7 +99,7 @@ func (cs *awsEnvironmentCredentialService) credentials() (awsCredentials, error)
if creds.RegionName == "" {
return creds, errors.New("no " + awsRegionEnvVar + " set in environment")
}
// SessionToken is required if using temporaty ENV credentials from assumed IAM role
// SessionToken is required if using temporary ENV credentials from assumed IAM role
// Missing SessionToken results with 403 s3 error.
creds.SessionToken = os.Getenv(sessionTokenEnvVar)
if creds.SessionToken == "" {
Expand All @@ -101,6 +111,101 @@ func (cs *awsEnvironmentCredentialService) credentials() (awsCredentials, error)
return creds, nil
}

// awsProfileCredentialService represents a credential provider for AWS that extracts credentials from the AWS
// credentials file
type awsProfileCredentialService struct {

// Path to the credentials file.
//
// If empty will look for "AWS_SHARED_CREDENTIALS_FILE" env variable. If the
// env value is empty will default to current user's home directory.
// Linux/OSX: "$HOME/.aws/credentials"
// Windows: "%USERPROFILE%\.aws\credentials"
Path string `json:"path,omitempty"`

// AWS Profile to extract credentials from the credentials file. If empty
// will default to environment variable "AWS_PROFILE" or "default" if
// environment variable is also not set.
Profile string `json:"profile,omitempty"`

RegionName string `json:"aws_region"`

logger logging.Logger
}

func (cs *awsProfileCredentialService) credentials() (awsCredentials, error) {
var creds awsCredentials

filename, err := cs.path()
if err != nil {
return creds, err
}

cfg, err := ini.Load(filename)
if err != nil {
return creds, fmt.Errorf("failed to read credentials file: %v", err)
}

profile, err := cfg.GetSection(cs.profile())
if err != nil {
return creds, fmt.Errorf("failed to get profile: %v", err)
}

creds.AccessKey = profile.Key(accessKeyGlobalSetting).String()
if creds.AccessKey == "" {
return creds, fmt.Errorf("profile \"%v\" in credentials file %v does not contain \"%v\"", cs.Profile, cs.Path, accessKeyGlobalSetting)
}

creds.SecretKey = profile.Key(secretKeyGlobalSetting).String()
if creds.SecretKey == "" {
return creds, fmt.Errorf("profile \"%v\" in credentials file %v does not contain \"%v\"", cs.Profile, cs.Path, secretKeyGlobalSetting)
}

creds.SessionToken = profile.Key(securityTokenGlobalSetting).String() //default to empty string

if cs.RegionName == "" {
if cs.RegionName = os.Getenv(awsRegionEnvVar); cs.RegionName == "" {
return creds, errors.New("no " + awsRegionEnvVar + " set in environment or configuration")
}
}
creds.RegionName = cs.RegionName

return creds, nil
}

func (cs *awsProfileCredentialService) path() (string, error) {
if len(cs.Path) != 0 {
return cs.Path, nil
}

if cs.Path = os.Getenv(awsCredentialsFileEnvVar); len(cs.Path) != 0 {
return cs.Path, nil
}

homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("user home directory not found: %w", err)
}

cs.Path = filepath.Join(homeDir, ".aws", "credentials")

return cs.Path, nil
}

func (cs *awsProfileCredentialService) profile() string {
if cs.Profile != "" {
return cs.Profile
}

cs.Profile = os.Getenv(awsProfileEnvVar)

if cs.Profile == "" {
cs.Profile = "default"
}

return cs.Profile
}

// awsMetadataCredentialService represents an EC2 metadata service credential provider for AWS
type awsMetadataCredentialService struct {
RoleName string `json:"iam_role,omitempty"`
Expand Down
219 changes: 219 additions & 0 deletions plugins/rest/aws_test.go
Expand Up @@ -11,10 +11,13 @@ import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"

"github.com/open-policy-agent/opa/util/test"

"github.com/open-policy-agent/opa/logging"
)

Expand Down Expand Up @@ -98,6 +101,222 @@ func TestEnvironmentCredentialService(t *testing.T) {
}
}

func TestProfileCredentialService(t *testing.T) {

defaultKey := "AKIAIOSFODNN7EXAMPLE"
defaultSecret := "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
defaultSessionToken := "AQoEXAMPLEH4aoAH0gNCAPy"
defaultRegion := "us-west-2"

fooKey := "AKIAI44QH8DHBEXAMPLE"
fooSecret := "je7MtGbClwBF/2Zp9Utk/h3yCo8nvbEXAMPLEKEY"
fooRegion := "us-east-1"

config := fmt.Sprintf(`
[default]
aws_access_key_id=%v
aws_secret_access_key=%v
aws_session_token=%v
[foo]
aws_access_key_id=%v
aws_secret_access_key=%v
`, defaultKey, defaultSecret, defaultSessionToken, fooKey, fooSecret)

files := map[string]string{
"example.ini": config,
}

test.WithTempFS(files, func(path string) {
cfgPath := filepath.Join(path, "example.ini")
cs := &awsProfileCredentialService{
Path: cfgPath,
Profile: "foo",
RegionName: fooRegion,
}
creds, err := cs.credentials()
if err != nil {
t.Fatal(err)
}

expected := awsCredentials{
AccessKey: fooKey,
SecretKey: fooSecret,
RegionName: fooRegion,
SessionToken: "",
}

if expected != creds {
t.Fatalf("Expected credentials %v but got %v", expected, creds)
}

// "default" profile
cs = &awsProfileCredentialService{
Path: cfgPath,
Profile: "",
RegionName: defaultRegion,
}

creds, err = cs.credentials()
if err != nil {
t.Fatal(err)
}

expected = awsCredentials{
AccessKey: defaultKey,
SecretKey: defaultSecret,
RegionName: defaultRegion,
SessionToken: defaultSessionToken,
}

if expected != creds {
t.Fatalf("Expected credentials %v but got %v", expected, creds)
}
})
}

func TestProfileCredentialServiceWithEnvVars(t *testing.T) {
defaultKey := "AKIAIOSFODNN7EXAMPLE"
defaultSecret := "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
defaultSessionToken := "AQoEXAMPLEH4aoAH0gNCAPy"
defaultRegion := "us-east-1"

config := fmt.Sprintf(`
[default]
aws_access_key_id=%v
aws_secret_access_key=%v
aws_session_token=%v
`, defaultKey, defaultSecret, defaultSessionToken)

files := map[string]string{
"example.ini": config,
}

test.WithTempFS(files, func(path string) {
cfgPath := filepath.Join(path, "example.ini")

os.Setenv(awsCredentialsFileEnvVar, cfgPath)
os.Setenv(awsProfileEnvVar, "default")

defer os.Unsetenv(awsCredentialsFileEnvVar)
defer os.Unsetenv(awsProfileEnvVar)

cs := &awsProfileCredentialService{}
creds, err := cs.credentials()
if err != nil {
t.Fatal(err)
}

expected := awsCredentials{
AccessKey: defaultKey,
SecretKey: defaultSecret,
RegionName: defaultRegion,
SessionToken: defaultSessionToken,
}

if expected != creds {
t.Fatalf("Expected credentials %v but got %v", expected, creds)
}
})
}

func TestProfileCredentialServiceWithDefaultPath(t *testing.T) {
defaultKey := "AKIAIOSFODNN7EXAMPLE"
defaultSecret := "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
defaultSessionToken := "AQoEXAMPLEH4aoAH0gNCAPy"
defaultRegion := "us-west-2"

config := fmt.Sprintf(`
[default]
aws_access_key_id=%v
aws_secret_access_key=%v
aws_session_token=%v
`, defaultKey, defaultSecret, defaultSessionToken)

files := map[string]string{}

test.WithTempFS(files, func(path string) {

os.Setenv("USERPROFILE", path)
os.Setenv("HOME", path)

defer os.Unsetenv("USERPROFILE")
defer os.Unsetenv("HOME")

cfgDir := filepath.Join(path, ".aws")
err := os.MkdirAll(cfgDir, os.ModePerm)
if err != nil {
t.Fatal(err)
}

if err := ioutil.WriteFile(filepath.Join(cfgDir, "credentials"), []byte(config), 0600); err != nil {
t.Fatalf("Unexpected error: %s", err)
}

cs := &awsProfileCredentialService{RegionName: defaultRegion}
creds, err := cs.credentials()
if err != nil {
t.Fatal(err)
}

expected := awsCredentials{
AccessKey: defaultKey,
SecretKey: defaultSecret,
RegionName: defaultRegion,
SessionToken: defaultSessionToken,
}

if expected != creds {
t.Fatalf("Expected credentials %v but got %v", expected, creds)
}
})
}

func TestProfileCredentialServiceWithError(t *testing.T) {
configNoAccessKeyID := `
[default]
aws_secret_access_key = secret
`

configNoSecret := `
[default]
aws_access_key_id=accessKey
`
tests := []struct {
note string
config string
}{
{
note: "no aws_access_key_id",
config: configNoAccessKeyID,
},
{
note: "no aws_secret_access_key",
config: configNoSecret,
},
}

for _, tc := range tests {
t.Run(tc.note, func(t *testing.T) {

files := map[string]string{
"example.ini": tc.config,
}

test.WithTempFS(files, func(path string) {
cfgPath := filepath.Join(path, "example.ini")
cs := &awsProfileCredentialService{
Path: cfgPath,
}
_, err := cs.credentials()
if err == nil {
t.Fatal("Expected error but got nil")
}
})
})
}
}

func TestMetadataCredentialService(t *testing.T) {
ts := ec2CredTestServer{}
ts.start()
Expand Down

0 comments on commit 7d73d52

Please sign in to comment.