diff --git a/docs/data-sources/item_login.md b/docs/data-sources/item_login.md index 06433b6..65759cd 100644 --- a/docs/data-sources/item_login.md +++ b/docs/data-sources/item_login.md @@ -13,7 +13,7 @@ Use this data source to get information on an existing Login. ## Example Usage ```terraform -# Find the identifier of the resource you want to import: +# Option 1: Find the identifier of the resource you want to import: # # $ bw list items --search "Mysql Root Credentials" | jq '.[] .id' # ? Master password: [hidden] @@ -25,6 +25,11 @@ data "bitwarden_item_login" "database_credentials" { id = "ec4e447f-9aed-4203-b834-c8f3848828f7" } +# Option 2: Use filters directly in the resource declaration +data "bitwarden_item_secure_note" "ssh_notes" { + search = "SSH Private Key" +} + # Later to be accessed as # ... = data.bitwarden_item_login.database_credentials.username # ... = data.bitwarden_item_login.database_credentials.password @@ -42,9 +47,14 @@ data "bitwarden_item_login" "database_credentials" { ## Schema -### Required +### Optional +- `filter_collection_id` (String) Filter search results by collection ID +- `filter_folder_id` (String) Filter search results by folder ID +- `filter_organization_id` (String) Filter search results by organization ID +- `filter_url` (String) Filter search results by URL - `id` (String) Identifier. +- `search` (String) Search items matching the search string. Can be combined with filters to narrow down the search. ### Read-Only diff --git a/docs/data-sources/item_secure_note.md b/docs/data-sources/item_secure_note.md index 16d6ef1..b0a1cba 100644 --- a/docs/data-sources/item_secure_note.md +++ b/docs/data-sources/item_secure_note.md @@ -13,7 +13,7 @@ Use this data source to get information on an existing Secure Note. ## Example Usage ```terraform -# Find the identifier of the resource you want to import: +# Option 1: Find the identifier of the resource you want to import: # # $ bw list items --search "SSH Private Key" | jq '.[] .id' # ? Master password: [hidden] @@ -25,6 +25,11 @@ data "bitwarden_item_secure_note" "ssh_notes" { id = "a9e19f26-1b8c-4568-bc09-191e2cf56ed6" } +# Option 2: Use filters directly in the resource declaration +data "bitwarden_item_secure_note" "ssh_notes" { + search = "SSH Private Key" +} + # Later to be accessed as # ... = data.bitwarden_item_secure_note.ssh_notes.notes # @@ -41,9 +46,13 @@ data "bitwarden_item_secure_note" "ssh_notes" { ## Schema -### Required +### Optional +- `filter_collection_id` (String) Filter search results by collection ID +- `filter_folder_id` (String) Filter search results by folder ID +- `filter_organization_id` (String) Filter search results by organization ID - `id` (String) Identifier. +- `search` (String) Search items matching the search string. Can be combined with filters to narrow down the search. ### Read-Only diff --git a/docs/index.md b/docs/index.md index 6f17f9f..5822497 100644 --- a/docs/index.md +++ b/docs/index.md @@ -33,6 +33,11 @@ resource "bitwarden_item_login" "example" { username = "service-account" password = "" } + +# Use Bitwarden Resource +data "bitwarden_item_login" "example" { + search = "Example" +} ``` ## Authentication diff --git a/docs/resources/item_login.md b/docs/resources/item_login.md index aef1e20..310e24d 100644 --- a/docs/resources/item_login.md +++ b/docs/resources/item_login.md @@ -54,6 +54,7 @@ resource "bitwarden_item_login" "administrative-user" { - `favorite` (Boolean) Mark as a Favorite to have item appear at the top of your Vault in the UI. - `field` (Block List) Extra fields. (see [below for nested schema](#nestedblock--field)) - `folder_id` (String) Identifier of the folder. +- `id` (String) Identifier. - `notes` (String, Sensitive) Notes. - `organization_id` (String) Identifier of the organization. - `password` (String, Sensitive) Login password. @@ -67,7 +68,6 @@ resource "bitwarden_item_login" "administrative-user" { - `attachments` (List of Object) List of item attachments. (see [below for nested schema](#nestedatt--attachments)) - `creation_date` (String) Date the item was created. - `deleted_date` (String) Date the item was deleted. -- `id` (String) Identifier. - `revision_date` (String) Last time the item was updated. diff --git a/docs/resources/item_secure_note.md b/docs/resources/item_secure_note.md index 8baef84..ae9746d 100644 --- a/docs/resources/item_secure_note.md +++ b/docs/resources/item_secure_note.md @@ -55,6 +55,7 @@ EOT - `favorite` (Boolean) Mark as a Favorite to have item appear at the top of your Vault in the UI. - `field` (Block List) Extra fields. (see [below for nested schema](#nestedblock--field)) - `folder_id` (String) Identifier of the folder. +- `id` (String) Identifier. - `notes` (String, Sensitive) Notes. - `organization_id` (String) Identifier of the organization. - `reprompt` (Boolean) Require master password “re-prompt” when displaying secret in the UI. @@ -64,7 +65,6 @@ EOT - `attachments` (List of Object) List of item attachments. (see [below for nested schema](#nestedatt--attachments)) - `creation_date` (String) Date the item was created. - `deleted_date` (String) Date the item was deleted. -- `id` (String) Identifier. - `revision_date` (String) Last time the item was updated. diff --git a/examples/data-sources/bitwarden_item_login/data-source.tf b/examples/data-sources/bitwarden_item_login/data-source.tf index 482d174..56fb11b 100644 --- a/examples/data-sources/bitwarden_item_login/data-source.tf +++ b/examples/data-sources/bitwarden_item_login/data-source.tf @@ -1,4 +1,4 @@ -# Find the identifier of the resource you want to import: +# Option 1: Find the identifier of the resource you want to import: # # $ bw list items --search "Mysql Root Credentials" | jq '.[] .id' # ? Master password: [hidden] @@ -10,6 +10,11 @@ data "bitwarden_item_login" "database_credentials" { id = "ec4e447f-9aed-4203-b834-c8f3848828f7" } +# Option 2: Use filters directly in the resource declaration +data "bitwarden_item_secure_note" "ssh_notes" { + search = "SSH Private Key" +} + # Later to be accessed as # ... = data.bitwarden_item_login.database_credentials.username # ... = data.bitwarden_item_login.database_credentials.password diff --git a/examples/data-sources/bitwarden_item_secure_note/data-source.tf b/examples/data-sources/bitwarden_item_secure_note/data-source.tf index 6c92678..030a913 100644 --- a/examples/data-sources/bitwarden_item_secure_note/data-source.tf +++ b/examples/data-sources/bitwarden_item_secure_note/data-source.tf @@ -1,4 +1,4 @@ -# Find the identifier of the resource you want to import: +# Option 1: Find the identifier of the resource you want to import: # # $ bw list items --search "SSH Private Key" | jq '.[] .id' # ? Master password: [hidden] @@ -10,6 +10,11 @@ data "bitwarden_item_secure_note" "ssh_notes" { id = "a9e19f26-1b8c-4568-bc09-191e2cf56ed6" } +# Option 2: Use filters directly in the resource declaration +data "bitwarden_item_secure_note" "ssh_notes" { + search = "SSH Private Key" +} + # Later to be accessed as # ... = data.bitwarden_item_secure_note.ssh_notes.notes # diff --git a/examples/quick/main.tf b/examples/quick/main.tf index 990ff34..40bfb84 100644 --- a/examples/quick/main.tf +++ b/examples/quick/main.tf @@ -30,7 +30,7 @@ resource "bitwarden_item_secure_note" "vpn_note" { # Read sensitive information from Bitwarden # Using Login information data "bitwarden_item_login" "mysql_credentials" { - id = "ec4e447f-9aed-4203-b834-c8f3848828f7" + search = "mysql/server-1" } # Later to be accessed as diff --git a/examples/quick/provider.tf b/examples/quick/provider.tf index 307ce20..52f5180 100644 --- a/examples/quick/provider.tf +++ b/examples/quick/provider.tf @@ -17,4 +17,9 @@ resource "bitwarden_item_login" "example" { name = "Example" username = "service-account" password = "" +} + +# Use Bitwarden Resource +data "bitwarden_item_login" "example" { + search = "Example" } \ No newline at end of file diff --git a/internal/bitwarden/bw/client.go b/internal/bitwarden/bw/client.go index f1d053f..0654872 100644 --- a/internal/bitwarden/bw/client.go +++ b/internal/bitwarden/bw/client.go @@ -16,6 +16,7 @@ type Client interface { GetObject(objType, itemOrSearch string) (*Object, error) GetSessionKey() string HasSessionKey() bool + ListObjects(objType string, options ...ListObjectsOption) ([]Object, error) LoginWithAPIKey(password, clientId, clientSecret string) error LoginWithPassword(username, password string) error Logout() error @@ -165,6 +166,31 @@ func (c *client) GetSessionKey() string { return c.sessionKey } +// ListObjects returns objects of a given type matching given filters. +func (c *client) ListObjects(objType string, options ...ListObjectsOption) ([]Object, error) { + args := []string{ + "list", + objType, + } + + for _, applyOption := range options { + applyOption(&args) + } + + out, err := c.cmdWithSession(args...).Run() + if err != nil { + return nil, remapError(err) + } + + var obj []Object + err = json.Unmarshal(out, &obj) + if err != nil { + return nil, newUnmarshallError(err, "list object", out) + } + + return obj, nil +} + // LoginWithPassword logs in using a password and retrieves the session key, // allowing authenticated requests using the client. func (c *client) LoginWithPassword(username, password string) error { diff --git a/internal/bitwarden/bw/client_options.go b/internal/bitwarden/bw/client_options.go new file mode 100644 index 0000000..9b12dcc --- /dev/null +++ b/internal/bitwarden/bw/client_options.go @@ -0,0 +1,34 @@ +package bw + +type ListObjectsOption func(args *[]string) +type ListObjectsOptionGenerator func(id string) ListObjectsOption + +func WithCollectionID(id string) ListObjectsOption { + return func(args *[]string) { + *args = append(*args, "--collectionid", id) + } +} + +func WithFolderID(id string) ListObjectsOption { + return func(args *[]string) { + *args = append(*args, "--folderid", id) + } +} + +func WithOrganizationID(id string) ListObjectsOption { + return func(args *[]string) { + *args = append(*args, "--organizationid", id) + } +} + +func WithSearch(search string) ListObjectsOption { + return func(args *[]string) { + *args = append(*args, "--search", search) + } +} + +func WithUrl(url string) ListObjectsOption { + return func(args *[]string) { + *args = append(*args, "--url", url) + } +} diff --git a/internal/bitwarden/bw/client_test.go b/internal/bitwarden/bw/client_test.go index 2f6a9fb..af9b969 100644 --- a/internal/bitwarden/bw/client_test.go +++ b/internal/bitwarden/bw/client_test.go @@ -32,3 +32,18 @@ func TestCreateObjectEncoding(t *testing.T) { assert.Equal(t, "create e30K", commandsExecuted()[1]) } } + +func TestListObjects(t *testing.T) { + removeMocks, commandsExecuted := test_command.MockCommands(t, map[string]string{ + "list item --folderid folder-id --collectionid collection-id --search search": `[]`, + }) + defer removeMocks(t) + + b := NewClient("dummy") + _, err := b.ListObjects("item", WithFolderID("folder-id"), WithCollectionID("collection-id"), WithSearch("search")) + + assert.NoError(t, err) + if assert.Len(t, commandsExecuted(), 1) { + assert.Equal(t, "list item --folderid folder-id --collectionid collection-id --search search", commandsExecuted()[0]) + } +} diff --git a/internal/bitwarden/bw/filter.go b/internal/bitwarden/bw/filter.go new file mode 100644 index 0000000..27feed5 --- /dev/null +++ b/internal/bitwarden/bw/filter.go @@ -0,0 +1,15 @@ +package bw + +func FilterObjectsByType(objs []Object, itemType ItemType) []Object { + if itemType == 0 { + return objs + } + + filtered := make([]Object, 0, len(objs)) + for _, obj := range objs { + if obj.Type == itemType { + filtered = append(filtered, obj) + } + } + return filtered +} diff --git a/internal/provider/data_source_item_login_test.go b/internal/provider/data_source_item_login_test.go index 1b79666..deb7e9d 100644 --- a/internal/provider/data_source_item_login_test.go +++ b/internal/provider/data_source_item_login_test.go @@ -1,6 +1,7 @@ package provider import ( + "fmt" "regexp" "testing" @@ -55,6 +56,77 @@ func TestAccDataSourceItemLoginFailsOnWrongResourceType(t *testing.T) { }) } +func TestAccDataSourceItemLoginBySearch(t *testing.T) { + resourceName := "bitwarden_item_login.foo" + + ensureVaultwardenConfigured(t) + + resource.UnitTest(t, resource.TestCase{ + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: tfConfigProvider() + tfConfigResourceFolder() + tfConfigResourceItemLogin(), + Check: checkItemLogin(resourceName), + }, + { + Config: tfConfigProvider() + tfConfigResourceFolder() + tfConfigResourceItemLogin() + tfConfigDataItemLoginWithSearchAndOrg("test-username"), + Check: checkItemLogin("data.bitwarden_item_login.foo_data"), + }, + { + Config: tfConfigProvider() + tfConfigResourceFolder() + tfConfigResourceItemLogin() + tfConfigResourceItemLoginDuplicate() + tfConfigDataItemLoginWithSearchAndOrg("test-username"), + Check: checkItemLogin("data.bitwarden_item_login.foo_data"), + }, + { + Config: tfConfigProvider() + tfConfigResourceFolder() + tfConfigResourceItemLogin() + tfConfigResourceItemLoginDuplicate() + tfConfigDataItemLoginWithSearchOnly("test-username"), + ExpectError: regexp.MustCompile("Error: too many objects found"), + }, + { + Config: tfConfigProvider() + tfConfigResourceFolder() + tfConfigResourceItemLogin() + tfConfigDataItemLoginWithSearchAndOrg("missing-item"), + ExpectError: regexp.MustCompile("Error: no object found matching the filter"), + }, + { + Config: tfConfigProvider() + tfConfigResourceFolder() + tfConfigResourceItemSecureNote(), + }, + { + Config: tfConfigProvider() + tfConfigResourceFolder() + tfConfigResourceItemSecureNote() + tfConfigDataItemLoginWithSearchAndOrg("secure-bar"), + ExpectError: regexp.MustCompile("Error: no object found matching the filter"), + }, + }, + }) +} + +func tfConfigDataItemLoginWithSearchAndOrg(search string) string { + return fmt.Sprintf(` +data "bitwarden_item_login" "foo_data" { + provider = bitwarden + + search = "%s" + filter_organization_id = "%s" +} +`, search, testOrganizationID) +} + +func tfConfigDataItemLoginWithSearchOnly(search string) string { + return fmt.Sprintf(` +data "bitwarden_item_login" "foo_data" { + provider = bitwarden + + search = "%s" +} +`, search) +} + +func tfConfigResourceItemLoginDuplicate() string { + return ` + resource "bitwarden_item_login" "foo_duplicate" { + provider = bitwarden + + name = "another item with username 'test-username'" + username = "test-username" + } + ` +} + func tfConfigDataItemLogin() string { return ` data "bitwarden_item_login" "foo_data" { diff --git a/internal/provider/object.go b/internal/provider/object.go index d334259..76f4a59 100644 --- a/internal/provider/object.go +++ b/internal/provider/object.go @@ -3,6 +3,7 @@ package provider import ( "context" "errors" + "fmt" "log" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" @@ -15,6 +16,10 @@ func objectCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) } func objectRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + if _, idProvided := d.GetOk(attributeID); !idProvided { + return diag.FromErr(objectSearch(d, meta)) + } + return diag.FromErr(objectOperation(ctx, d, func(secret bw.Object) (*bw.Object, error) { obj, err := meta.(bw.Client).GetObject(string(secret.Object), secret.ID) if obj != nil { @@ -37,6 +42,72 @@ func objectRead(ctx context.Context, d *schema.ResourceData, meta interface{}) d })) } +func objectSearch(d *schema.ResourceData, meta interface{}) error { + objType, ok := d.GetOk(attributeObject) + if !ok { + return fmt.Errorf("BUG: object type not set in the resource data") + } + + objs, err := meta.(bw.Client).ListObjects(fmt.Sprintf("%ss", objType), listOptionsFromData(d)...) + if err != nil { + return err + } + + // If the object is an item, also filter by type to avoid returning a login when a secure note is expected. + if bw.ObjectType(objType.(string)) == bw.ObjectTypeItem { + itemType, ok := d.GetOk(attributeType) + if !ok { + return fmt.Errorf("BUG: item type not set in the resource data") + } + + objs = bw.FilterObjectsByType(objs, bw.ItemType(itemType.(int))) + } + + if len(objs) == 0 { + return fmt.Errorf("no object found matching the filter") + } else if len(objs) > 1 { + log.Print("[WARN] Too many objects found:") + for _, obj := range objs { + log.Printf("[WARN] * %s (%s)", obj.Name, obj.ID) + } + return fmt.Errorf("too many objects found") + } + + obj := objs[0] + + // If the object exists but is marked as soft deleted, we return an error. This shouldn't happen + // in theory since we never pass the --trash flag to the Bitwarden CLI when listing objects. + if obj.DeletedDate != nil { + return errors.New("object is soft deleted") + } + + return objectDataFromStruct(d, &obj) +} + +func listOptionsFromData(d *schema.ResourceData) []bw.ListObjectsOption { + filters := []bw.ListObjectsOption{} + + filterMap := map[string]bw.ListObjectsOptionGenerator{ + attributeFilterSearch: bw.WithSearch, + attributeFilterCollectionId: bw.WithCollectionID, + attributeFilterFolderID: bw.WithFolderID, + attributeFilterOrganizationID: bw.WithOrganizationID, + attributeFilterURL: bw.WithUrl, + } + + for attribute, optionFunc := range filterMap { + v, ok := d.GetOk(attribute) + if !ok { + continue + } + + if v, ok := v.(string); ok && len(v) > 0 { + filters = append(filters, optionFunc(v)) + } + } + return filters +} + func objectReadIgnoreMissing(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { err := objectOperation(ctx, d, func(secret bw.Object) (*bw.Object, error) { return meta.(bw.Client).GetObject(string(secret.Object), secret.ID) diff --git a/internal/provider/schema.go b/internal/provider/schema.go index 300a52b..92824ca 100644 --- a/internal/provider/schema.go +++ b/internal/provider/schema.go @@ -13,7 +13,7 @@ const ( ) func loginSchema(schemaType schemaTypeEnum) map[string]*schema.Schema { - return map[string]*schema.Schema{ + base := map[string]*schema.Schema{ attributeLoginPassword: { Description: descriptionLoginPassword, Type: schema.TypeString, @@ -44,11 +44,20 @@ func loginSchema(schemaType schemaTypeEnum) map[string]*schema.Schema { Sensitive: false, }, } + + if schemaType == DataSource { + base[attributeFilterURL] = &schema.Schema{ + Description: descriptionFilterURL, + Type: schema.TypeString, + Optional: true, + } + } + return base } func baseSchema(schemaType schemaTypeEnum) map[string]*schema.Schema { - return map[string]*schema.Schema{ + base := map[string]*schema.Schema{ /* * Attributes that can be required */ @@ -56,7 +65,7 @@ func baseSchema(schemaType schemaTypeEnum) map[string]*schema.Schema { Description: descriptionIdentifier, Type: schema.TypeString, Computed: schemaType == Resource, - Required: schemaType == DataSource, + Optional: true, }, attributeName: { Description: descriptionName, @@ -181,6 +190,34 @@ func baseSchema(schemaType schemaTypeEnum) map[string]*schema.Schema { Computed: true, }, } + + if schemaType == DataSource { + base[attributeFilterCollectionId] = &schema.Schema{ + Description: descriptionFilterCollectionID, + Type: schema.TypeString, + Optional: true, + } + + base[attributeFilterFolderID] = &schema.Schema{ + Description: descriptionFilterFolderID, + Type: schema.TypeString, + Optional: true, + } + + base[attributeFilterOrganizationID] = &schema.Schema{ + Description: descriptionFilterOrganizationID, + Type: schema.TypeString, + Optional: true, + } + + base[attributeFilterSearch] = &schema.Schema{ + Description: descriptionFilterSearch, + Type: schema.TypeString, + Optional: true, + AtLeastOneOf: []string{attributeFilterSearch, attributeID}, + } + } + return base } func uriElem() *schema.Resource { diff --git a/internal/provider/schema_attributes.go b/internal/provider/schema_attributes.go index 7839500..08b326c 100644 --- a/internal/provider/schema_attributes.go +++ b/internal/provider/schema_attributes.go @@ -2,39 +2,45 @@ package provider const ( // Datasource and Resource field attributes - attributeAttachments = "attachments" - attributeCollectionIDs = "collection_ids" - attributeCreationDate = "creation_date" - attributeDeletedDate = "deleted_date" - attributeID = "id" - attributeFavorite = "favorite" - attributeField = "field" - attributeFieldName = "name" - attributeFieldBoolean = "boolean" - attributeFieldHidden = "hidden" - attributeFieldLinked = "linked" - attributeFieldText = "text" - attributeFolderID = "folder_id" - attributeAttachmentContent = "content" - attributeAttachmentItemID = "item_id" - attributeAttachmentFile = "file" - attributeAttachmentSize = "size" - attributeAttachmentSizeName = "size_name" - attributeAttachmentFileName = "file_name" - attributeAttachmentURL = "url" - attributeLoginPassword = "password" - attributeLoginUsername = "username" - attributeLoginURIs = "uri" - attributeLoginURIsMatch = "match" - attributeLoginURIsValue = "value" - attributeLoginTotp = "totp" - attributeName = "name" - attributeNotes = "notes" - attributeObject = "object" - attributeOrganizationID = "organization_id" - attributeReprompt = "reprompt" - attributeRevisionDate = "revision_date" - attributeType = "type" + attributeAttachments = "attachments" + attributeCollectionIDs = "collection_ids" + attributeCreationDate = "creation_date" + attributeDeletedDate = "deleted_date" + attributeID = "id" + attributeFavorite = "favorite" + attributeField = "field" + attributeFieldName = "name" + attributeFieldBoolean = "boolean" + attributeFieldHidden = "hidden" + attributeFieldLinked = "linked" + attributeFieldText = "text" + attributeFilterValues = "values" + attributeFolderID = "folder_id" + attributeAttachmentContent = "content" + attributeAttachmentItemID = "item_id" + attributeAttachmentFile = "file" + attributeAttachmentSize = "size" + attributeAttachmentSizeName = "size_name" + attributeAttachmentFileName = "file_name" + attributeAttachmentURL = "url" + attributeFilterCollectionId = "filter_collection_id" + attributeFilterFolderID = "filter_folder_id" + attributeFilterOrganizationID = "filter_organization_id" + attributeFilterSearch = "search" + attributeFilterURL = "filter_url" + attributeLoginPassword = "password" + attributeLoginUsername = "username" + attributeLoginURIs = "uri" + attributeLoginURIsMatch = "match" + attributeLoginURIsValue = "value" + attributeLoginTotp = "totp" + attributeName = "name" + attributeNotes = "notes" + attributeObject = "object" + attributeOrganizationID = "organization_id" + attributeReprompt = "reprompt" + attributeRevisionDate = "revision_date" + attributeType = "type" // Datasource and Resource field descriptions descriptionAttachments = "List of item attachments." @@ -48,6 +54,11 @@ const ( descriptionFieldLinked = "Value of a linked field." descriptionFieldName = "Name of the field." descriptionFieldText = "Value of a text field." + descriptionFilterCollectionID = "Filter search results by collection ID" + descriptionFilterFolderID = "Filter search results by folder ID" + descriptionFilterOrganizationID = "Filter search results by organization ID" + descriptionFilterSearch = "Search items matching the search string. Can be combined with filters to narrow down the search." + descriptionFilterURL = "Filter search results by URL" descriptionFolderID = "Identifier of the folder." descriptionIdentifier = "Identifier." descriptionInternal = "INTERNAL USE" // TODO: Manage to hide this from the users