Skip to content

Commit

Permalink
support sourcing secrets by key (#176)
Browse files Browse the repository at this point in the history
  • Loading branch information
maxlaverse authored Oct 18, 2024
1 parent 54c2778 commit 0be978b
Show file tree
Hide file tree
Showing 6 changed files with 199 additions and 44 deletions.
2 changes: 1 addition & 1 deletion docs/data-sources/secret.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,11 @@ resource "kubernetes_secret" "vpn_credentials" {
### Optional

- `id` (String) Identifier.
- `key` (String) Name.
- `organization_id` (String) Identifier of the organization.

### Read-Only

- `key` (String) Name.
- `note` (String) Note.
- `project_id` (String) Identifier of the project.
- `value` (String) Value.
1 change: 1 addition & 0 deletions internal/bitwarden/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,6 @@ type SecretsManager interface {
EditSecret(ctx context.Context, secret models.Secret) (*models.Secret, error)
GetProject(ctx context.Context, project models.Project) (*models.Project, error)
GetSecret(ctx context.Context, secret models.Secret) (*models.Secret, error)
GetSecretByKey(ctx context.Context, secretKey string) (*models.Secret, error)
LoginWithAccessToken(ctx context.Context, accessKey string) error
}
95 changes: 77 additions & 18 deletions internal/bitwarden/embedded/secrets_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strings"

"github.com/golang-jwt/jwt/v5"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/maxlaverse/terraform-provider-bitwarden/internal/bitwarden"
"github.com/maxlaverse/terraform-provider-bitwarden/internal/bitwarden/crypto/keybuilder"
"github.com/maxlaverse/terraform-provider-bitwarden/internal/bitwarden/crypto/symmetrickey"
Expand All @@ -21,10 +22,15 @@ type SecretsManager interface {
EditSecret(ctx context.Context, secret models.Secret) (*models.Secret, error)
GetProject(ctx context.Context, project models.Project) (*models.Project, error)
GetSecret(ctx context.Context, secret models.Secret) (*models.Secret, error)
GetSecretByKey(ctx context.Context, secretKey string) (*models.Secret, error)
LoginWithAccessToken(ctx context.Context, accessToken string) error
}
type SecretsManagerOptions func(c bitwarden.SecretsManager)

type SecretType interface {
webapi.SecretSummary | webapi.Secret
}

func WithSecretsManagerHttpOptions(opts ...webapi.Options) SecretsManagerOptions {
return func(c bitwarden.SecretsManager) {
c.(*secretsManager).clientOpts = opts
Expand Down Expand Up @@ -159,6 +165,44 @@ func (v *secretsManager) GetSecret(ctx context.Context, secret models.Secret) (*
return decSecret, nil
}

func (v *secretsManager) GetSecretByKey(ctx context.Context, secretKey string) (*models.Secret, error) {
if v.mainEncryptionKey == nil {
return nil, models.ErrLoggedOut
}

secretSummaries, err := v.client.GetSecrets(ctx, v.mainOrganizationId)
if err != nil {
return nil, fmt.Errorf("error listing secrets: %w", err)
}

secretIDsFound := []models.Secret{}
for _, secret := range secretSummaries {
decSecret, err := decryptSecret(secret, *v.mainEncryptionKey)
if err != nil {
return nil, fmt.Errorf("error decrypting secret summary '%s': %w", secret.ID, err)
}
if decSecret.Key == secretKey {
secretIDsFound = append(secretIDsFound, *decSecret)
}
}

if len(secretIDsFound) == 0 {
return nil, models.ErrObjectNotFound
}
if len(secretIDsFound) > 1 {
objects := []string{}
for _, obj := range secretIDsFound {
objects = append(objects, fmt.Sprintf("%s (%s)", obj.Key, obj.ID))
}
tflog.Warn(ctx, "Too many objects found", map[string]interface{}{"objects": objects})
return nil, fmt.Errorf("too many objects found")
}

return v.GetSecret(ctx, models.Secret{
ID: secretIDsFound[0].ID,
})
}

func (v *secretsManager) LoginWithAccessToken(ctx context.Context, accessToken string) error {
clientId, clientSecret, accessKeyEncryptionKey, err := parseAccessToken(accessToken)
if err != nil {
Expand Down Expand Up @@ -210,35 +254,50 @@ func (v *secretsManager) LoginWithAccessToken(ctx context.Context, accessToken s
return nil
}

func decryptSecret(webapiSecret webapi.Secret, mainEncryptionKey symmetrickey.Key) (*models.Secret, error) {
secretKey, err := decryptStringIfNotEmpty(webapiSecret.Key, mainEncryptionKey)
if err != nil {
return nil, fmt.Errorf("error decrypting secret name: %w", err)
func decryptSecret[T SecretType](webapiSecret T, mainEncryptionKey symmetrickey.Key) (*models.Secret, error) {
var summary webapi.SecretSummary
var secretNote, secretValue string

switch secret := any(webapiSecret).(type) {
case webapi.SecretSummary:
summary = secret

case webapi.Secret:
var err error

summary = secret.SecretSummary
secretNote, err = decryptStringIfNotEmpty(secret.Note, mainEncryptionKey)
if err != nil {
return nil, fmt.Errorf("error decrypting secret note: %w", err)
}

secretValue, err = decryptStringIfNotEmpty(secret.Value, mainEncryptionKey)
if err != nil {
return nil, fmt.Errorf("error decrypting secret value: %w", err)
}
default:
return nil, fmt.Errorf("unsupported type")
}

secretNote, err := decryptStringIfNotEmpty(webapiSecret.Note, mainEncryptionKey)
if err != nil {
return nil, fmt.Errorf("error decrypting secret note: %w", err)
projectId := ""
if len(summary.Projects) > 0 {
projectId = summary.Projects[0].ID
}

secretValue, err := decryptStringIfNotEmpty(webapiSecret.Value, mainEncryptionKey)
secretKey, err := decryptStringIfNotEmpty(summary.Key, mainEncryptionKey)
if err != nil {
return nil, fmt.Errorf("error decrypting secret value: %w", err)
return nil, fmt.Errorf("error decrypting secret key: %w", err)
}

projectId := ""
if len(webapiSecret.Projects) > 0 {
projectId = webapiSecret.Projects[0].ID
}
return &models.Secret{
CreationDate: webapiSecret.CreationDate,
ID: webapiSecret.ID,
CreationDate: summary.CreationDate,
ID: summary.ID,
Key: secretKey,
Note: secretNote,
RevisionDate: webapiSecret.RevisionDate,
Value: secretValue,
OrganizationID: summary.OrganizationID,
ProjectID: projectId,
OrganizationID: webapiSecret.OrganizationID,
RevisionDate: summary.RevisionDate,
Value: secretValue,
}, nil
}

Expand Down
113 changes: 90 additions & 23 deletions internal/provider/resource_secret_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,56 +8,123 @@ import (
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
)

func TestResourceSecretSchema(t *testing.T) {
resource.Test(t, resource.TestCase{
ProviderFactories: providerFactories,
Steps: []resource.TestStep{
{
Config: tfConfigSecretsManagerProvider() + tfConfigDataSecretWithoutAnyInput(),
ExpectError: regexp.MustCompile("Error: Missing required argument"),
},
{
Config: tfConfigSecretsManagerProvider() + tfConfigDataSecretTooManyInput(),
ExpectError: regexp.MustCompile(": conflicts"),
},
},
})
}

func TestResourceSecret(t *testing.T) {
tfConfigSecretsManagerProvider()
if len(testProjectId) == 0 {
t.Skip("Skipping test due to missing project_id")
}
resource.Test(t, resource.TestCase{
ProviderFactories: providerFactories,
Steps: []resource.TestStep{
{
Config: tfConfigSecretsManagerProvider() + tfConfigResourceSecret(),
Check: resource.ComposeTestCheckFunc(
resource.TestMatchResourceAttr("bitwarden_secret.foo", attributeID, regexp.MustCompile("^([a-z0-9-]+)$")),
resource.TestCheckResourceAttr("bitwarden_secret.foo", attributeKey, "login-bar"),
resource.TestCheckResourceAttr("bitwarden_secret.foo", attributeValue, "value-bar"),
resource.TestCheckResourceAttr("bitwarden_secret.foo", attributeNote, "note-bar"),
resource.TestCheckResourceAttr("bitwarden_secret.foo", attributeProjectID, testProjectId),
),
},
{
Config: tfConfigSecretsManagerProvider() + tfConfigResourceSecret() + tfConfigDataSecret(),
Check: resource.ComposeTestCheckFunc(
resource.TestMatchResourceAttr("data.bitwarden_secret.foo_data", attributeID, regexp.MustCompile("^([a-z0-9-]+)$")),
resource.TestCheckResourceAttr("data.bitwarden_secret.foo_data", attributeKey, "login-bar"),
resource.TestCheckResourceAttr("data.bitwarden_secret.foo_data", attributeValue, "value-bar"),
resource.TestCheckResourceAttr("data.bitwarden_secret.foo_data", attributeNote, "note-bar"),
resource.TestCheckResourceAttr("data.bitwarden_secret.foo_data", attributeProjectID, testProjectId),
),
Config: tfConfigSecretsManagerProvider() + tfConfigDataSecretWithoutAnyInput(),
ExpectError: regexp.MustCompile("Error: Missing required argument"),
},
{
Config: tfConfigSecretsManagerProvider() + tfConfigDataSecretTooManyInput(),
ExpectError: regexp.MustCompile(": conflicts"),
},
{
Config: tfConfigSecretsManagerProvider() + tfConfigResourceSecret("foo"),
Check: checkSecret("bitwarden_secret.foo"),
},
// Test Sourcing Secret by ID
{
Config: tfConfigSecretsManagerProvider() + tfConfigResourceSecret("foo") + tfConfigDataSecretByID("bitwarden_secret.foo.id"),
Check: checkSecret("data.bitwarden_secret.foo_data"),
},
// Test Sourcing Secret by ID with NO MATCH
{
Config: tfConfigSecretsManagerProvider() + tfConfigResourceSecret("foo") + tfConfigDataSecretByID("\"27a0007a-a517-4f25-8c2e-baf31ca3b034\""),
ExpectError: regexp.MustCompile("Error: object not found"),
},
// Test Sourcing Secret by KEY
{
Config: tfConfigSecretsManagerProvider() + tfConfigResourceSecret("foo") + tfConfigDataSecretByKey(),
Check: checkSecret("data.bitwarden_secret.foo_data"),
},
// Test Sourcing Secret with MULTIPLE MATCHES
{
Config: tfConfigSecretsManagerProvider() + tfConfigResourceSecret("foo") + tfConfigResourceSecret("foo2") + tfConfigDataSecretByKey(),
ExpectError: regexp.MustCompile("Error: too many objects found"),
},
},
})
}

func tfConfigDataSecret() string {
func checkSecret(fullRessourceName string) resource.TestCheckFunc {
return resource.ComposeTestCheckFunc(
resource.TestMatchResourceAttr(fullRessourceName, attributeID, regexp.MustCompile("^([a-z0-9-]+)$")),
resource.TestCheckResourceAttr(fullRessourceName, attributeKey, "login-bar"),
resource.TestCheckResourceAttr(fullRessourceName, attributeValue, "value-bar"),
resource.TestCheckResourceAttr(fullRessourceName, attributeNote, "note-bar"),
resource.TestCheckResourceAttr(fullRessourceName, attributeProjectID, testProjectId),
)
}
func tfConfigDataSecretByID(id string) string {
return fmt.Sprintf(`
data "bitwarden_secret" "foo_data" {
provider = bitwarden
id = %s
}
`, id)
}

func tfConfigDataSecretByKey() string {
return `
data "bitwarden_secret" "foo_data" {
provider = bitwarden
key = "login-bar"
}
`
}

func tfConfigDataSecretWithoutAnyInput() string {
return `
data "bitwarden_secret" "foo_data" {
provider = bitwarden
}
`
}

func tfConfigDataSecretTooManyInput() string {
return `
data "bitwarden_secret" "foo_data" {
provider = bitwarden
id = bitwarden_secret.foo.id
key = "something"
id = "something"
}
`
}

func tfConfigResourceSecret() string {
func tfConfigResourceSecret(resourceName string) string {
return fmt.Sprintf(`
resource "bitwarden_secret" "foo" {
resource "bitwarden_secret" "%s" {
provider = bitwarden
key = "login-bar"
value = "value-bar"
note = "note-bar"
project_id ="%s"
}
`, testProjectId)
`, resourceName, testProjectId)
}
13 changes: 11 additions & 2 deletions internal/provider/schema_secret.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package provider
import "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"

func secretSchema(schemaType schemaTypeEnum) map[string]*schema.Schema {
return map[string]*schema.Schema{
baseSchema := map[string]*schema.Schema{
attributeID: {
Description: descriptionIdentifier,
Type: schema.TypeString,
Expand All @@ -13,7 +13,7 @@ func secretSchema(schemaType schemaTypeEnum) map[string]*schema.Schema {
attributeKey: {
Description: descriptionName,
Type: schema.TypeString,
Computed: schemaType == DataSource,
Optional: schemaType == DataSource,
Required: schemaType == Resource,
},
attributeValue: {
Expand Down Expand Up @@ -41,4 +41,13 @@ func secretSchema(schemaType schemaTypeEnum) map[string]*schema.Schema {
Required: schemaType == Resource,
},
}

if schemaType == DataSource {
baseSchema[attributeID].AtLeastOneOf = []string{attributeID, attributeKey}
baseSchema[attributeID].ConflictsWith = []string{attributeKey}
baseSchema[attributeKey].AtLeastOneOf = []string{attributeID, attributeKey}
baseSchema[attributeKey].ConflictsWith = []string{attributeID}
}

return baseSchema
}
19 changes: 19 additions & 0 deletions internal/provider/secret.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package provider
import (
"context"
"errors"
"fmt"

"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
Expand Down Expand Up @@ -48,6 +49,10 @@ func secretCreate(ctx context.Context, d *schema.ResourceData, bwsClient bitward
}

func secretRead(ctx context.Context, d *schema.ResourceData, bwsClient bitwarden.SecretsManager) diag.Diagnostics {
if _, idProvided := d.GetOk(attributeID); !idProvided {
return diag.FromErr(secretSearch(ctx, d, bwsClient))
}

return diag.FromErr(secretOperation(ctx, d, func(ctx context.Context, secretReq models.Secret) (*models.Secret, error) {
secret, err := bwsClient.GetSecret(ctx, secretReq)
if secret != nil {
Expand All @@ -60,6 +65,20 @@ func secretRead(ctx context.Context, d *schema.ResourceData, bwsClient bitwarden
}))
}

func secretSearch(ctx context.Context, d *schema.ResourceData, bwsClient bitwarden.SecretsManager) error {
secretKey, ok := d.GetOk(attributeKey)
if !ok {
return fmt.Errorf("BUG: secret key not set in the resource data")
}

secret, err := bwsClient.GetSecretByKey(ctx, secretKey.(string))
if err != nil {
return err
}

return secretDataFromStruct(ctx, d, secret)
}

func secretOperation(ctx context.Context, d *schema.ResourceData, operation secretOperationFunc) error {
secret, err := operation(ctx, secretStructFromData(ctx, d))
if err != nil {
Expand Down

0 comments on commit 0be978b

Please sign in to comment.