Skip to content

Commit

Permalink
Feature: Add support for "Service Accounts" (#545)
Browse files Browse the repository at this point in the history
* Adding the ability for service accounts

To help improve how terraform can be run inside a CI/CD pipeline
without needed to take additional steps for Admin actions. This allows
inbuilt support for supplying username and password (service account) to
then apply the actions using those details.

* Fixing lint issues

* Correcting validation
  • Loading branch information
MovieStoreGuy authored Nov 17, 2024
1 parent add0a00 commit 94c91d4
Show file tree
Hide file tree
Showing 9 changed files with 246 additions and 31 deletions.
44 changes: 37 additions & 7 deletions internal/definition/provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,11 @@ func New() *schema.Provider {
return &schema.Provider{
Schema: map[string]*schema.Schema{
"auth_token": {
Type: schema.TypeString,
Optional: true,
DefaultFunc: schema.EnvDefaultFunc("SFX_AUTH_TOKEN", ""),
Description: "Splunk Observability Cloud auth token",
Type: schema.TypeString,
Optional: true,
ConflictsWith: []string{"email", "password", "organization_id"},
DefaultFunc: schema.EnvDefaultFunc("SFX_AUTH_TOKEN", ""),
Description: "Splunk Observability Cloud auth token",
},
"api_url": {
Type: schema.TypeString,
Expand Down Expand Up @@ -70,6 +71,24 @@ func New() *schema.Provider {
Default: 30,
Description: "Maximum retry wait for a single HTTP call in seconds. Defaults to 30",
},
"email": {
Type: schema.TypeString,
Optional: true,
ConflictsWith: []string{"auth_token"},
Description: "Used to create a session token instead of an API token, it requires the account to be configured to login with Email and Password",
},
"password": {
Type: schema.TypeString,
Optional: true,
ConflictsWith: []string{"auth_token"},
Description: "Used to create a session token instead of an API token, it requires the account to be configured to login with Email and Password",
},
"organization_id": {
Type: schema.TypeString,
Optional: true,
ConflictsWith: []string{"auth_token"},
Description: "Required if the user is configured to be part of multiple organizations",
},
},
ResourcesMap: map[string]*schema.Resource{
team.ResourceName: team.NewResource(),
Expand All @@ -83,9 +102,14 @@ func New() *schema.Provider {
}

func configureProvider(ctx context.Context, data *schema.ResourceData) (any, diag.Diagnostics) {
var meta pmeta.Meta
meta := &pmeta.Meta{
Email: data.Get("email").(string),
Password: data.Get("password").(string),
OrganizationID: data.Get("organization_id").(string),
}

for _, lookup := range pmeta.NewDefaultProviderLookups() {
if err := lookup.Do(ctx, &meta); err != nil {
if err := lookup.Do(ctx, meta); err != nil {
tflog.Debug(
ctx,
"Issue trying to load external provider configuration, skipping",
Expand Down Expand Up @@ -116,6 +140,11 @@ func configureProvider(ctx context.Context, data *schema.ResourceData) (any, dia
waitmax = time.Duration(int64((data.Get("retry_wait_max_seconds").(int)))) * time.Second
)

token, err := meta.LoadSessionToken(ctx)
if err != nil {
return nil, tfext.AsErrorDiagnostics(err)
}

rc := retryablehttp.NewClient()
rc.RetryMax = attempts
rc.RetryWaitMin = waitmin
Expand All @@ -129,7 +158,8 @@ func configureProvider(ctx context.Context, data *schema.ResourceData) (any, dia
MaxIdleConnsPerHost: 100,
})

meta.Client, err = signalfx.NewClient(meta.AuthToken,
meta.Client, err = signalfx.NewClient(
token,
signalfx.APIUrl(meta.APIURL),
signalfx.HTTPClient(rc.StandardClient()),
signalfx.UserAgent(fmt.Sprintf("Terraform terraform-provider-signalfx/%s", version.ProviderVersion)),
Expand Down
2 changes: 1 addition & 1 deletion internal/definition/provider/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ func TestProviderConfiguration(t *testing.T) {
name: "no details provided",
details: make(map[string]any),
expect: diag.Diagnostics{
{Severity: diag.Error, Summary: "auth token not set"},
{Severity: diag.Error, Summary: "missing auth token or email and password"},
},
},
{
Expand Down
47 changes: 39 additions & 8 deletions internal/providermeta/meta.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/signalfx/signalfx-go"
"github.com/signalfx/signalfx-go/sessiontoken"
"go.uber.org/multierr"

tfext "github.com/splunk-terraform/terraform-provider-signalfx/internal/tfextension"
Expand All @@ -27,10 +28,13 @@ var (
// It is abstracted out from the provider definition to make it easier
// to test CRUD operations within unit tests.
type Meta struct {
AuthToken string `json:"auth_token"`
APIURL string `json:"api_url"`
CustomAppURL string `json:"custom_app_url"`
Client *signalfx.Client `json:"-"`
AuthToken string `json:"auth_token"`
APIURL string `json:"api_url"`
CustomAppURL string `json:"custom_app_url"`
Client *signalfx.Client `json:"-"`
Email string `json:"email"`
Password string `json:"password"`
OrganizationID string `json:"org_id"`
}

// LoadClient returns the configured [signalfx.Client] ready to use.
Expand Down Expand Up @@ -68,11 +72,38 @@ func LoadApplicationURL(ctx context.Context, meta any, fragments ...string) stri
return u.String()
}

func (s *Meta) Validate() (errs error) {
if s.AuthToken == "" {
errs = multierr.Append(errs, errors.New("auth token not set"))
// LoadSessionToken will use the provider username and password
// so that it can be used as the token through the interaction.
func (m *Meta) LoadSessionToken(ctx context.Context) (string, error) {
if m.AuthToken != "" {
return m.AuthToken, nil
}
if s.APIURL == "" {

client, err := signalfx.NewClient("", signalfx.APIUrl(m.APIURL))
if err != nil {
return "", err
}

resp, err := client.CreateSessionToken(ctx, &sessiontoken.CreateTokenRequest{
Email: m.Email,
Password: m.Password,
OrganizationId: m.OrganizationID,
})
if err != nil {
return "", err
}

// TODO: determine if any additional fields would be useful for debugging.
tflog.Info(ctx, "Created new session token")

return resp.AccessToken, nil
}

func (m *Meta) Validate() (errs error) {
if m.AuthToken == "" && (m.Email == "" || m.Password == "") {
errs = multierr.Append(errs, errors.New("missing auth token or email and password"))
}
if m.APIURL == "" {
errs = multierr.Append(errs, errors.New("api url is not set"))
}
return errs
Expand Down
102 changes: 101 additions & 1 deletion internal/providermeta/meta_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,14 @@ package pmeta

import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"

"github.com/signalfx/signalfx-go/sessiontoken"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

Expand Down Expand Up @@ -102,7 +108,7 @@ func TestMetaValidation(t *testing.T) {
{
name: "meta not set",
meta: Meta{},
errVal: "auth token not set; api url is not set",
errVal: "missing auth token or email and password; api url is not set",
},
{
name: "state valid",
Expand All @@ -111,6 +117,22 @@ func TestMetaValidation(t *testing.T) {
APIURL: "http://api.signalfx.com",
},
},
{
name: "Email only provided",
meta: Meta{
Email: "example@com",
APIURL: "http://api.signalfx.com",
},
errVal: "missing auth token or email and password",
},
{
name: "password only provided",
meta: Meta{
Password: "derp",
APIURL: "http://api.signalfx.com",
},
errVal: "missing auth token or email and password",
},
} {

t.Run(tc.name, func(t *testing.T) {
Expand All @@ -124,3 +146,81 @@ func TestMetaValidation(t *testing.T) {
})
}
}

func TestMetaToken(t *testing.T) {
t.Parallel()

for _, tc := range []struct {
name string
token string
handler http.HandlerFunc
email string
password string
expect string
errVal string
}{
{
name: "missing values",
token: "",
handler: func(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(io.Discard, r.Body)
_ = r.Body.Close()

http.Error(w, "failed auth", http.StatusBadRequest)
},
email: "",
password: "",
expect: "",
errVal: "route \"/v2/session\" had issues with status code 400",
},
{
name: "token already provided",
token: "aaccbbb",
handler: func(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(io.Discard, r.Body)
_ = r.Body.Close()

http.Error(w, "should not be called", http.StatusBadRequest)
},
email: "",
password: "",
expect: "aaccbbb",
errVal: "",
},
{
name: "username password provided",
token: "",
handler: func(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(io.Discard, r.Body)
_ = r.Body.Close()

_ = json.NewEncoder(w).Encode(&sessiontoken.Token{AccessToken: "secret"})
},
email: "user@example",
password: "notsosecret",
expect: "secret",
errVal: "",
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s := httptest.NewServer(tc.handler)
t.Cleanup(s.Close)

m := &Meta{
APIURL: s.URL,
AuthToken: tc.token,
Email: tc.email,
Password: tc.password,
}

if token, err := m.LoadSessionToken(context.Background()); tc.errVal != "" {
assert.Equal(t, tc.expect, token, "Must match the expected value")
assert.EqualError(t, err, tc.errVal, "Must match the expected value")
} else {
assert.Equal(t, tc.expect, token, "Must match the expected value")
assert.NoError(t, err, "Must not error")
}
})
}
}
2 changes: 1 addition & 1 deletion internal/tftest/meta_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ func TestNewAcceptanceConfigure(t *testing.T) {
name: "no values set",
envs: map[string]string{},
issues: diag.Diagnostics{
{Severity: diag.Error, Summary: "auth token not set"},
{Severity: diag.Error, Summary: "missing auth token or email and password"},
{Severity: diag.Error, Summary: "api url is not set"},
},
},
Expand Down
1 change: 1 addition & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package main

import (
"github.com/hashicorp/terraform-plugin-sdk/v2/plugin"

"github.com/splunk-terraform/terraform-provider-signalfx/signalfx"
)

Expand Down
45 changes: 36 additions & 9 deletions signalfx/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package signalfx

import (
"context"
"encoding/json"
"fmt"
"log"
Expand Down Expand Up @@ -37,10 +38,11 @@ func Provider() *schema.Provider {
sfxProvider = &schema.Provider{
Schema: map[string]*schema.Schema{
"auth_token": {
Type: schema.TypeString,
Optional: true,
DefaultFunc: schema.EnvDefaultFunc("SFX_AUTH_TOKEN", ""),
Description: "Splunk Observability Cloud auth token",
Type: schema.TypeString,
Optional: true,
ConflictsWith: []string{"email", "password", "organization_id"},
DefaultFunc: schema.EnvDefaultFunc("SFX_AUTH_TOKEN", ""),
Description: "Splunk Observability Cloud auth token",
},
"api_url": {
Type: schema.TypeString,
Expand Down Expand Up @@ -78,6 +80,24 @@ func Provider() *schema.Provider {
Default: 30,
Description: "Maximum retry wait for a single HTTP call in seconds. Defaults to 30",
},
"email": {
Type: schema.TypeString,
Optional: true,
ConflictsWith: []string{"auth_token"},
Description: "Used to create a session token instead of an API token, it requires the account to be configured to login with Email and Password",
},
"password": {
Type: schema.TypeString,
Optional: true,
ConflictsWith: []string{"auth_token"},
Description: "Used to create a session token instead of an API token, it requires the account to be configured to login with Email and Password",
},
"organization_id": {
Type: schema.TypeString,
Optional: true,
ConflictsWith: []string{"auth_token"},
Description: "Required if the user is configured to be part of multiple organizations",
},
},
DataSourcesMap: map[string]*schema.Resource{
"signalfx_dimension_values": dataSourceDimensionValues(),
Expand Down Expand Up @@ -159,16 +179,17 @@ func signalfxConfigure(data *schema.ResourceData) (interface{}, error) {
config.AuthToken = token.(string)
}

if config.AuthToken == "" {
return &config, fmt.Errorf("auth_token: required field is not set")
}
if url, ok := data.GetOk("api_url"); ok {
config.APIURL = url.(string)
}
if customAppURL, ok := data.GetOk("custom_app_url"); ok {
config.CustomAppURL = customAppURL.(string)
}

if err = config.Validate(); err != nil {
return nil, err
}

netTransport := logging.NewTransport("SignalFx", &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Expand Down Expand Up @@ -199,10 +220,16 @@ func signalfxConfigure(data *schema.ResourceData) (interface{}, error) {
retryClient.HTTPClient.Transport = netTransport
standardClient := retryClient.StandardClient()

client, err := sfx.NewClient(config.AuthToken,
token, err := config.LoadSessionToken(context.Background())
if err != nil {
return nil, err
}

client, err := sfx.NewClient(
token,
sfx.APIUrl(config.APIURL),
sfx.HTTPClient(standardClient),
sfx.UserAgent(fmt.Sprintf(providerUserAgent)),
sfx.UserAgent(providerUserAgent),
)
if err != nil {
return &config, err
Expand Down
Loading

0 comments on commit 94c91d4

Please sign in to comment.