diff --git a/docs/data-sources/secret.md b/docs/data-sources/secret.md index 7b61337..8ced8c8 100644 --- a/docs/data-sources/secret.md +++ b/docs/data-sources/secret.md @@ -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. diff --git a/internal/bitwarden/client.go b/internal/bitwarden/client.go index 41b69be..cfcd85a 100644 --- a/internal/bitwarden/client.go +++ b/internal/bitwarden/client.go @@ -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 } diff --git a/internal/bitwarden/embedded/secrets_manager.go b/internal/bitwarden/embedded/secrets_manager.go index 2d09c93..8bc7e4c 100644 --- a/internal/bitwarden/embedded/secrets_manager.go +++ b/internal/bitwarden/embedded/secrets_manager.go @@ -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" @@ -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 @@ -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 { @@ -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 } diff --git a/internal/provider/resource_secret_test.go b/internal/provider/resource_secret_test.go index 6d1f1ca..6ca5dc2 100644 --- a/internal/provider/resource_secret_test.go +++ b/internal/provider/resource_secret_test.go @@ -8,7 +8,24 @@ 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") } @@ -16,42 +33,92 @@ func TestResourceSecret(t *testing.T) { 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" @@ -59,5 +126,5 @@ func tfConfigResourceSecret() string { note = "note-bar" project_id ="%s" } -`, testProjectId) +`, resourceName, testProjectId) } diff --git a/internal/provider/schema_secret.go b/internal/provider/schema_secret.go index 4dc9c35..c86404d 100644 --- a/internal/provider/schema_secret.go +++ b/internal/provider/schema_secret.go @@ -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, @@ -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: { @@ -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 } diff --git a/internal/provider/secret.go b/internal/provider/secret.go index 6d7c779..e34cae9 100644 --- a/internal/provider/secret.go +++ b/internal/provider/secret.go @@ -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" @@ -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 { @@ -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 {