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

support sourcing secrets by key #176

Merged
merged 1 commit into from
Oct 18, 2024
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
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
Loading