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

remote.vault: introduce component to retrieve secrets from Vault #3428

Merged
merged 15 commits into from
May 8, 2023
Merged
Show file tree
Hide file tree
Changes from 13 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Main (unreleased)
- New Grafana Agent Flow components:
- `prometheus.operator.servicemonitors` discovers ServiceMonitor resources in your Kubernetes cluster and scrape
the targets they reference. (@captncraig, @marctc, @jcreixell)
- `remote.vault` retrieves a secret from Vault. (@rfratto)

- Added coalesce function to river stdlib. (@jkroepke)

Expand Down
1 change: 1 addition & 0 deletions component/all/all.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,5 @@ import (
_ "github.com/grafana/agent/component/prometheus/scrape" // Import prometheus.scrape
_ "github.com/grafana/agent/component/remote/http" // Import remote.http
_ "github.com/grafana/agent/component/remote/s3" // Import remote.s3
_ "github.com/grafana/agent/component/remote/vault" // Import remote.vault
)
339 changes: 339 additions & 0 deletions component/remote/vault/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,339 @@
package vault

import (
"context"
"fmt"

"github.com/grafana/agent/pkg/flow/rivertypes"
vault "github.com/hashicorp/vault/api"
"github.com/hashicorp/vault/api/auth/approle"
"github.com/hashicorp/vault/api/auth/aws"
"github.com/hashicorp/vault/api/auth/azure"
"github.com/hashicorp/vault/api/auth/gcp"
"github.com/hashicorp/vault/api/auth/kubernetes"
"github.com/hashicorp/vault/api/auth/ldap"
"github.com/hashicorp/vault/api/auth/userpass"
)

// An authMethod can configure a Vault client to be authenticated using a
// specific authentication method.
//
// The vaultAuthenticate method will be called each time a new token is needed
// (e.g., if renewal failed). vaultAuthenticate method may return a nil secret
// if the authentication method does not generate a secret.
type authMethod interface {
vaultAuthenticate(context.Context, *vault.Client) (*vault.Secret, error)
}

// AuthArguments defines a single authentication type in a remote.vault
// component instance. These are embedded as an enum field so only one may be
// set per AuthArguments.
type AuthArguments struct {
AuthToken *AuthToken `river:"token,block,optional"`
AuthAppRole *AuthAppRole `river:"approle,block,optional"`
AuthAWS *AuthAWS `river:"aws,block,optional"`
AuthAzure *AuthAzure `river:"azure,block,optional"`
AuthGCP *AuthGCP `river:"gcp,block,optional"`
AuthKubernetes *AuthKubernetes `river:"kubernetes,block,optional"`
AuthLDAP *AuthLDAP `river:"ldap,block,optional"`
AuthUserPass *AuthUserPass `river:"userpass,block,optional"`
AuthCustom *AuthCustom `river:"custom,block,optional"`
}

func (a *AuthArguments) authMethod() authMethod {
switch {
case a.AuthToken != nil:
return a.AuthToken
case a.AuthAppRole != nil:
return a.AuthAppRole
case a.AuthAWS != nil:
return a.AuthAWS
case a.AuthAzure != nil:
return a.AuthAzure
case a.AuthGCP != nil:
return a.AuthGCP
case a.AuthKubernetes != nil:
return a.AuthKubernetes
case a.AuthLDAP != nil:
return a.AuthLDAP
case a.AuthUserPass != nil:
return a.AuthUserPass
case a.AuthCustom != nil:
return a.AuthCustom
}

panic("remote.vault: unreachable")
}

// AuthToken authenticates against Vault with a token.
type AuthToken struct {
Token rivertypes.Secret `river:"token,attr"`
}

func (a *AuthToken) vaultAuthenticate(ctx context.Context, cli *vault.Client) (*vault.Secret, error) {
cli.SetToken(string(a.Token))
return nil, nil
}

// AuthAppRole authenticates against Vault with AppRole.
type AuthAppRole struct {
RoleID string `river:"role_id,attr"`
Secret rivertypes.Secret `river:"secret,attr"`
WrappingToken bool `river:"wrapping_token,attr,optional"`
MountPath string `river:"mouth_path,attr,optional"`
}

func (a *AuthAppRole) vaultAuthenticate(ctx context.Context, cli *vault.Client) (*vault.Secret, error) {
secret := &approle.SecretID{FromString: string(a.Secret)}

var opts []approle.LoginOption
if a.WrappingToken {
opts = append(opts, approle.WithWrappingToken())
}
if a.MountPath != "" {
opts = append(opts, approle.WithMountPath(a.MountPath))
}

auth, err := approle.NewAppRoleAuth(a.RoleID, secret, opts...)
if err != nil {
return nil, fmt.Errorf("auth.approle: %w", err)
}
s, err := cli.Auth().Login(ctx, auth)
if err != nil {
return nil, fmt.Errorf("auth.approle: %w", err)
}
return s, nil
}

// AuthAWS authenticates against Vault with AWS.
type AuthAWS struct {
Copy link
Member

Choose a reason for hiding this comment

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

Seems that upstream passes some default values, should we follow suit? Also, there's a withNonce method we're not using, not sure if it was left out on purpose.

https://github.com/hashicorp/vault/blob/api/auth/aws/v0.4.0/api/auth/aws/aws.go#L59-L62

Copy link
Member Author

@rfratto rfratto May 5, 2023

Choose a reason for hiding this comment

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

I left nonce out on purpose, I don't think it makes sense to set a static nonce for the lifetime of the component, since the idea of a nonce is that it's used just once.

// Type specifies the mechanism used to authenticate with AWS. Should be
// either ec2 or iam.
Type string `river:"type,attr"`
Region string `river:"region,attr,optional"`
Role string `river:"role,attr,optional"`
IAMServerIDHeader string `river:"iam_server_id_header,attr,optional"`
// EC2SignatureType specifies the signature to use against EC2. Only used
// when Type is ec2. Valid options are identity and pkcs7 (default).
EC2SignatureType string `river:"ec2_signature_type,attr,optional"`
MountPath string `river:"mouth_path,attr,optional"`
}

func (a *AuthAWS) vaultAuthenticate(ctx context.Context, cli *vault.Client) (*vault.Secret, error) {
var opts []aws.LoginOption

switch a.Type {
case "":
return nil, fmt.Errorf("auth.aws: type must not be empty")
case "ec2":
opts = append(opts, aws.WithEC2Auth())
case "iam":
opts = append(opts, aws.WithIAMAuth())
default:
return nil, fmt.Errorf("auth.aws: unrecognized type %q, expected one of ec2,iam", a.Type)
}
if a.Region != "" {
opts = append(opts, aws.WithRegion(a.Region))
}
if a.Role != "" {
opts = append(opts, aws.WithRole(a.Role))
}
if a.IAMServerIDHeader != "" {
opts = append(opts, aws.WithIAMServerIDHeader(a.IAMServerIDHeader))
}
switch a.EC2SignatureType {
case "", "pkcs7":
opts = append(opts, aws.WithPKCS7Signature())
case "identity":
opts = append(opts, aws.WithIdentitySignature())
default:
return nil, fmt.Errorf("auth.aws: unrecognized ec2_signature_type %q, expected one of pkcs7,identity", a.Type)
}
if a.MountPath != "" {
opts = append(opts, aws.WithMountPath(a.MountPath))
}

auth, err := aws.NewAWSAuth(opts...)
if err != nil {
return nil, fmt.Errorf("auth.aws: %w", err)
}
s, err := cli.Auth().Login(ctx, auth)
if err != nil {
return nil, fmt.Errorf("auth.aws: %w", err)
}
return s, nil
}

// AuthAzure authenticates against Vault with Azure.
type AuthAzure struct {
Role string `river:"role,attr"`
ResourceURL string `river:"resource_url,attr,optional"`
MountPath string `river:"mouth_path,attr,optional"`
}

func (a *AuthAzure) vaultAuthenticate(ctx context.Context, cli *vault.Client) (*vault.Secret, error) {
var opts []azure.LoginOption

if a.ResourceURL != "" {
opts = append(opts, azure.WithResource(a.ResourceURL))
}
if a.MountPath != "" {
opts = append(opts, azure.WithMountPath(a.MountPath))
}

auth, err := azure.NewAzureAuth(a.Role, opts...)
if err != nil {
return nil, fmt.Errorf("auth.azure: %w", err)
}
s, err := cli.Auth().Login(ctx, auth)
if err != nil {
return nil, fmt.Errorf("auth.azure: %w", err)
}
return s, nil
}

// AuthGCP authenticates against Vault with GCP.
type AuthGCP struct {
Role string `river:"role,attr"`
// Type specifies the mechanism used to authenticate with GCS. Should be
// either gce or iam.
Type string `river:"type,attr"`
IAMServiceAccount string `river:"iam_service_account,attr,optional"`
MountPath string `river:"mouth_path,attr,optional"`
}

func (a *AuthGCP) vaultAuthenticate(ctx context.Context, cli *vault.Client) (*vault.Secret, error) {
var opts []gcp.LoginOption

switch a.Type {
case "gce":
opts = append(opts, gcp.WithGCEAuth())
case "iam":
if a.IAMServiceAccount == "" {
return nil, fmt.Errorf("auth.gcp: iam_service_account must be provided when type is iam")
}
opts = append(opts, gcp.WithIAMAuth(a.IAMServiceAccount))
default:
return nil, fmt.Errorf("auth.gcp: unrecognized type %q, expected one of gce,iam", a.Type)
}

if a.MountPath != "" {
opts = append(opts, gcp.WithMountPath(a.MountPath))
}

auth, err := gcp.NewGCPAuth(a.Role, opts...)
if err != nil {
return nil, fmt.Errorf("auth.gcp: %w", err)
}
s, err := cli.Auth().Login(ctx, auth)
if err != nil {
return nil, fmt.Errorf("auth.gcp: %w", err)
}
return s, nil
}

// AuthKubernetes authenticates against Vault with Kubernetes.
type AuthKubernetes struct {
Role string `river:"role,attr"`
ServiceAccountTokenFile string `river:"service_account_file,attr,optional"`
MountPath string `river:"mouth_path,attr,optional"`
}

func (a *AuthKubernetes) vaultAuthenticate(ctx context.Context, cli *vault.Client) (*vault.Secret, error) {
var opts []kubernetes.LoginOption

if a.ServiceAccountTokenFile != "" {
opts = append(opts, kubernetes.WithServiceAccountTokenPath(a.ServiceAccountTokenFile))
}
if a.MountPath != "" {
opts = append(opts, kubernetes.WithMountPath(a.MountPath))
}

auth, err := kubernetes.NewKubernetesAuth(a.Role, opts...)
if err != nil {
return nil, fmt.Errorf("auth.kubernetes: %w", err)
}
s, err := cli.Auth().Login(ctx, auth)
if err != nil {
return nil, fmt.Errorf("auth.kubernetes: %w", err)
}
return s, nil
}

// AuthLDAP authenticates against Vault with LDAP.
type AuthLDAP struct {
Username string `river:"username,attr"`
Password rivertypes.Secret `river:"password,attr"`
MountPath string `river:"mouth_path,attr,optional"`
}

func (a *AuthLDAP) vaultAuthenticate(ctx context.Context, cli *vault.Client) (*vault.Secret, error) {
secret := &ldap.Password{FromString: string(a.Password)}

var opts []ldap.LoginOption

if a.MountPath != "" {
opts = append(opts, ldap.WithMountPath(a.MountPath))
}

auth, err := ldap.NewLDAPAuth(a.Username, secret, opts...)
if err != nil {
return nil, fmt.Errorf("auth.ldap: %w", err)
}
s, err := cli.Auth().Login(ctx, auth)
if err != nil {
return nil, fmt.Errorf("auth.ldap: %w", err)
}
return s, nil
}

// AuthUserPass authenticates against Vault with a username and password.
type AuthUserPass struct {
Username string `river:"username,attr"`
Password rivertypes.Secret `river:"password,attr"`
MountPath string `river:"mouth_path,attr,optional"`
}

func (a *AuthUserPass) vaultAuthenticate(ctx context.Context, cli *vault.Client) (*vault.Secret, error) {
secret := &userpass.Password{FromString: string(a.Password)}

var opts []userpass.LoginOption

if a.MountPath != "" {
opts = append(opts, userpass.WithMountPath(a.MountPath))
}

auth, err := userpass.NewUserpassAuth(a.Username, secret, opts...)
if err != nil {
return nil, fmt.Errorf("auth.userpass: %w", err)
}
s, err := cli.Auth().Login(ctx, auth)
if err != nil {
return nil, fmt.Errorf("auth.userpass: %w", err)
}
return s, nil
}

// AuthCustom provides a custom authentication method.
type AuthCustom struct {
// Path to use for logging in (e.g., auth/kubernetes/login, etc.)
Path string `river:"path,attr"`
Data map[string]rivertypes.Secret `river:"data,attr"`
}

// Login implements vault.AuthMethod.
func (a *AuthCustom) Login(ctx context.Context, client *vault.Client) (*vault.Secret, error) {
data := make(map[string]interface{}, len(a.Data))
for k, v := range a.Data {
data[k] = string(v)
}
return client.Logical().WriteWithContext(ctx, a.Path, data)
}

func (a *AuthCustom) vaultAuthenticate(ctx context.Context, cli *vault.Client) (*vault.Secret, error) {
s, err := cli.Auth().Login(ctx, a)
if err != nil {
return nil, fmt.Errorf("auth.custom: %w", err)
}
return s, nil
}
38 changes: 38 additions & 0 deletions component/remote/vault/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package vault

import (
"context"
"fmt"
"strings"

vault "github.com/hashicorp/vault/api"
)

// secretStore abstracts away the details for how a secret is retrieved from a
// vault.Client.
type secretStore interface {
Read(ctx context.Context, args *Arguments) (*vault.Secret, error)
}

// TODO(rfratto): support logical stores.

type kvStore struct{ c *vault.Client }

func (ks *kvStore) Read(ctx context.Context, args *Arguments) (*vault.Secret, error) {
// Split the path so we know which kv mount we want to use.
pathParts := strings.SplitN(args.Path, "/", 2)
if len(pathParts) != 2 {
return nil, fmt.Errorf("missing mount path in %q", args.Path)
}

kv := ks.c.KVv2(pathParts[0])
kvSecret, err := kv.Get(ctx, pathParts[1])
if err != nil {
return nil, err
}

// kvSecret.Data contains unwrapped data. Let's assign that to the raw secret
// and return it. This is a bit of a hack, but should work just fine.
kvSecret.Raw.Data = kvSecret.Data
return kvSecret.Raw, nil
}
Loading