diff --git a/docs/data-sources/item.md b/docs/data-sources/item.md index 4ecb8c29..cb0a9495 100644 --- a/docs/data-sources/item.md +++ b/docs/data-sources/item.md @@ -34,8 +34,9 @@ data "onepassword_item" "example" { ### Read-Only -- `category` (String) The category of the item. One of ["login" "password" "database" "secure_note"] +- `category` (String) The category of the item. One of ["login" "password" "database" "secure_note" "document"] - `database` (String) (Only applies to the database category) The name of the database. +- `file` (Block List) A list of files attached to the item. (see [below for nested schema](#nestedblock--file)) - `hostname` (String) (Only applies to the database category) The address where the database can be found - `id` (String) The Terraform resource identifier for this item in the format `vaults//items/`. - `password` (String, Sensitive) Password for this item. @@ -46,12 +47,24 @@ data "onepassword_item" "example" { - `url` (String) The primary URL for the item. - `username` (String) Username for this item. + +### Nested Schema for `file` + +Read-Only: + +- `content` (String, Sensitive) The content of the file. +- `content_base64` (String, Sensitive) The content of the file in base64 encoding. (Use this for binary files.) +- `id` (String) The UUID of the file. +- `name` (String) The name of the file. + + ### Nested Schema for `section` Read-Only: - `field` (Block List) (see [below for nested schema](#nestedblock--section--field)) +- `file` (Block List) A list of files attached to the section. (see [below for nested schema](#nestedblock--section--file)) - `id` (String) A unique identifier for the section. - `label` (String) The label for the section. @@ -65,3 +78,14 @@ Read-Only: - `purpose` (String) Purpose indicates this is a special field: a username, password, or notes field. - `type` (String) The type of value stored in the field. - `value` (String, Sensitive) The value of the field. + + + +### Nested Schema for `section.file` + +Read-Only: + +- `content` (String, Sensitive) The content of the file. +- `content_base64` (String, Sensitive) The content of the file in base64 encoding. (Use this for binary files.) +- `id` (String) The UUID of the file. +- `name` (String) The name of the file. diff --git a/internal/onepassword/cli/op.go b/internal/onepassword/cli/op.go index daca6583..d4fcc470 100644 --- a/internal/onepassword/cli/op.go +++ b/internal/onepassword/cli/op.go @@ -208,6 +208,20 @@ func (op *OP) delete(ctx context.Context, item *onepassword.Item, vaultUuid stri return nil, op.execJson(ctx, nil, nil, p("item"), p("delete"), p(item.ID), f("vault", vaultUuid)) } +func (op *OP) GetFileContent(ctx context.Context, file *onepassword.File, itemUuid, vaultUuid string) ([]byte, error) { + versionErr := op.checkCliVersion(ctx) + if versionErr != nil { + return nil, versionErr + } + ref := fmt.Sprintf("op://%s/%s/%s", vaultUuid, itemUuid, file.ID) + tflog.Debug(ctx, "reading file content from: "+ref) + res, err := op.execRaw(ctx, nil, p("read"), p(ref)) + if err != nil { + return nil, err + } + return res, nil +} + func (op *OP) execJson(ctx context.Context, dst any, stdin []byte, args ...opArg) error { result, err := op.execRaw(ctx, stdin, args...) if err != nil { diff --git a/internal/onepassword/client.go b/internal/onepassword/client.go index 5a50d9fb..0265bb12 100644 --- a/internal/onepassword/client.go +++ b/internal/onepassword/client.go @@ -18,6 +18,7 @@ type Client interface { CreateItem(ctx context.Context, item *onepassword.Item, vaultUuid string) (*onepassword.Item, error) UpdateItem(ctx context.Context, item *onepassword.Item, vaultUuid string) (*onepassword.Item, error) DeleteItem(ctx context.Context, item *onepassword.Item, vaultUuid string) error + GetFileContent(ctx context.Context, file *onepassword.File, itemUUid, vaultUuid string) ([]byte, error) } type ClientConfig struct { diff --git a/internal/onepassword/connect/connect_client.go b/internal/onepassword/connect/connect_client.go index 26de26f2..f7a092d4 100644 --- a/internal/onepassword/connect/connect_client.go +++ b/internal/onepassword/connect/connect_client.go @@ -39,6 +39,10 @@ func (c *Client) DeleteItem(_ context.Context, item *onepassword.Item, vaultUuid return c.connectClient.DeleteItem(item, vaultUuid) } +func (w *Client) GetFileContent(_ context.Context, file *onepassword.File, itemUUID, vaultUUID string) ([]byte, error) { + return w.connectClient.GetFileContent(file) +} + func NewClient(connectHost, connectToken, providerUserAgent string) *Client { return &Client{connectClient: connect.NewClientWithUserAgent(connectHost, connectToken, providerUserAgent)} } diff --git a/internal/provider/const.go b/internal/provider/const.go index 6bb4f167..51c1d8f5 100644 --- a/internal/provider/const.go +++ b/internal/provider/const.go @@ -29,6 +29,14 @@ const ( sectionIDDescription = "A unique identifier for the section." sectionLabelDescription = "The label for the section." sectionFieldsDescription = "A list of custom fields in the section." + sectionFilesDescription = "A list of files attached to the section." + + filesDescription = "A list of files attached to the item." + fileDescription = "A file attached to the item." + fileIDDescription = "The UUID of the file." + fileNameDescription = "The name of the file." + fileContentDescription = "The content of the file." + fileContentBase64Description = "The content of the file in base64 encoding. (Use this for binary files.)" fieldDescription = "A custom field." fieldIDDescription = "A unique identifier for the field." @@ -58,6 +66,7 @@ var ( strings.ToLower(string(op.Database)), strings.ToLower(string(op.SecureNote)), } + dataSourceCategories = append(categories, strings.ToLower(string(op.Document))) fieldPurposes = []string{ string(op.FieldPurposeUsername), diff --git a/internal/provider/onepassword_item_data_source.go b/internal/provider/onepassword_item_data_source.go index 3f6a6733..3d41422a 100644 --- a/internal/provider/onepassword_item_data_source.go +++ b/internal/provider/onepassword_item_data_source.go @@ -2,6 +2,7 @@ package provider import ( "context" + "encoding/base64" "errors" "fmt" "strings" @@ -47,12 +48,21 @@ type OnePasswordItemDataSourceModel struct { Password types.String `tfsdk:"password"` NoteValue types.String `tfsdk:"note_value"` Section []OnePasswordItemSectionModel `tfsdk:"section"` + File []OnePasswordItemFileModel `tfsdk:"file"` +} + +type OnePasswordItemFileModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Content types.String `tfsdk:"content"` + ContentBase64 types.String `tfsdk:"content_base64"` } type OnePasswordItemSectionModel struct { ID types.String `tfsdk:"id"` Label types.String `tfsdk:"label"` Field []OnePasswordItemFieldModel `tfsdk:"field"` + File []OnePasswordItemFileModel `tfsdk:"file"` } type OnePasswordItemFieldModel struct { @@ -68,6 +78,29 @@ func (d *OnePasswordItemDataSource) Metadata(ctx context.Context, req datasource } func (d *OnePasswordItemDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + fileNestedObjectSchema := schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + MarkdownDescription: fileIDDescription, + Computed: true, + }, + "name": schema.StringAttribute{ + MarkdownDescription: fileNameDescription, + Computed: true, + }, + "content": schema.StringAttribute{ + MarkdownDescription: fileContentDescription, + Computed: true, + Sensitive: true, + }, + "content_base64": schema.StringAttribute{ + MarkdownDescription: fileContentBase64Description, + Computed: true, + Sensitive: true, + }, + }, + } + resp.Schema = schema.Schema{ // This description is used by the documentation generator and the language server. MarkdownDescription: "Use this data source to get details of an item by its vault uuid and either the title or the uuid of the item.", @@ -98,7 +131,7 @@ func (d *OnePasswordItemDataSource) Schema(ctx context.Context, req datasource.S Computed: true, }, "category": schema.StringAttribute{ - MarkdownDescription: fmt.Sprintf(enumDescription, categoryDescription, categories), + MarkdownDescription: fmt.Sprintf(enumDescription, categoryDescription, dataSourceCategories), Computed: true, }, "url": schema.StringAttribute{ @@ -184,9 +217,17 @@ func (d *OnePasswordItemDataSource) Schema(ctx context.Context, req datasource.S }, }, }, + "file": schema.ListNestedBlock{ + MarkdownDescription: sectionFilesDescription, + NestedObject: fileNestedObjectSchema, + }, }, }, }, + "file": schema.ListNestedBlock{ + MarkdownDescription: filesDescription, + NestedObject: fileNestedObjectSchema, + }, }, } } @@ -267,6 +308,26 @@ func (d *OnePasswordItemDataSource) Read(ctx context.Context, req datasource.Rea } } + for _, f := range item.Files { + if f.Section != nil && f.Section.ID == s.ID { + content, err := f.Content() + if err != nil { + // content has not yet been loaded, fetch it + content, err = d.client.GetFileContent(ctx, f, item.ID, item.Vault.ID) + } + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read file, got error: %s", err)) + } + file := OnePasswordItemFileModel{ + ID: types.StringValue(f.ID), + Name: types.StringValue(f.Name), + Content: types.StringValue(string(content)), + ContentBase64: types.StringValue(base64.StdEncoding.EncodeToString(content)), + } + section.File = append(section.File, file) + } + } + data.Section = append(data.Section, section) } @@ -298,6 +359,25 @@ func (d *OnePasswordItemDataSource) Read(ctx context.Context, req datasource.Rea } } + for _, f := range item.Files { + if f.Section == nil { + content, err := f.Content() + if err != nil { + // content has not yet been loaded, fetch it + content, err = d.client.GetFileContent(ctx, f, item.ID, item.Vault.ID) + } + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read file, got error: %s", err)) + } + file := OnePasswordItemFileModel{ + ID: types.StringValue(f.ID), + Name: types.StringValue(f.Name), + Content: types.StringValue(string(content)), + ContentBase64: types.StringValue(base64.StdEncoding.EncodeToString(content)), + } + data.File = append(data.File, file) + } + } // Write logs using the tflog package // Documentation: https://terraform.io/plugin/log tflog.Trace(ctx, "read an item data source") diff --git a/internal/provider/onepassword_item_data_source_test.go b/internal/provider/onepassword_item_data_source_test.go index fc5d8543..af2d3113 100644 --- a/internal/provider/onepassword_item_data_source_test.go +++ b/internal/provider/onepassword_item_data_source_test.go @@ -1,6 +1,7 @@ package provider import ( + "encoding/base64" "fmt" "strings" "testing" @@ -138,6 +139,94 @@ func TestAccItemPasswordDatabase(t *testing.T) { }) } +func TestAccItemDocument(t *testing.T) { + expectedItem := generateDocumentItem() + expectedVault := op.Vault{ + ID: expectedItem.Vault.ID, + Name: "Name of the vault", + Description: "This vault will be retrieved", + } + + testServer := setupTestServer(expectedItem, expectedVault, t) + defer testServer.Close() + + first_content, err := expectedItem.Files[0].Content() + if err != nil { + t.Fatalf("Error getting content of first file: %v", err) + } + + second_content, err := expectedItem.Files[1].Content() + if err != nil { + t.Fatalf("Error getting content of second file: %v", err) + } + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccProviderConfig(testServer.URL) + testAccItemDataSourceConfig(expectedItem.Vault.ID, expectedItem.ID), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("data.onepassword_item.test", "id", fmt.Sprintf("vaults/%s/items/%s", expectedVault.ID, expectedItem.ID)), + resource.TestCheckResourceAttr("data.onepassword_item.test", "vault", expectedVault.ID), + resource.TestCheckResourceAttr("data.onepassword_item.test", "title", expectedItem.Title), + resource.TestCheckResourceAttr("data.onepassword_item.test", "uuid", expectedItem.ID), + resource.TestCheckResourceAttr("data.onepassword_item.test", "category", strings.ToLower(string(expectedItem.Category))), + resource.TestCheckResourceAttr("data.onepassword_item.test", "file.0.id", expectedItem.Files[0].ID), + resource.TestCheckResourceAttr("data.onepassword_item.test", "file.0.name", expectedItem.Files[0].Name), + resource.TestCheckResourceAttr("data.onepassword_item.test", "file.0.content", string(first_content)), + resource.TestCheckResourceAttr("data.onepassword_item.test", "file.1.id", expectedItem.Files[1].ID), + resource.TestCheckResourceAttr("data.onepassword_item.test", "file.1.name", expectedItem.Files[1].Name), + resource.TestCheckResourceAttr("data.onepassword_item.test", "file.1.content_base64", base64.StdEncoding.EncodeToString(second_content)), + ), + }, + }, + }) +} + +func TestAccItemLoginWithFiles(t *testing.T) { + expectedItem := generateLoginItemWithFiles() + expectedVault := op.Vault{ + ID: expectedItem.Vault.ID, + Name: "Name of the vault", + Description: "This vault will be retrieved", + } + + testServer := setupTestServer(expectedItem, expectedVault, t) + defer testServer.Close() + + first_content, err := expectedItem.Files[0].Content() + if err != nil { + t.Fatalf("Error getting content of first file: %v", err) + } + + second_content, err := expectedItem.Files[1].Content() + if err != nil { + t.Fatalf("Error getting content of second file: %v", err) + } + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccProviderConfig(testServer.URL) + testAccItemDataSourceConfig(expectedItem.Vault.ID, expectedItem.ID), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("data.onepassword_item.test", "id", fmt.Sprintf("vaults/%s/items/%s", expectedVault.ID, expectedItem.ID)), + resource.TestCheckResourceAttr("data.onepassword_item.test", "vault", expectedVault.ID), + resource.TestCheckResourceAttr("data.onepassword_item.test", "title", expectedItem.Title), + resource.TestCheckResourceAttr("data.onepassword_item.test", "uuid", expectedItem.ID), + resource.TestCheckResourceAttr("data.onepassword_item.test", "category", strings.ToLower(string(expectedItem.Category))), + resource.TestCheckResourceAttr("data.onepassword_item.test", "section.0.file.0.id", expectedItem.Files[0].ID), + resource.TestCheckResourceAttr("data.onepassword_item.test", "section.0.file.0.name", expectedItem.Files[0].Name), + resource.TestCheckResourceAttr("data.onepassword_item.test", "section.0.file.0.content", string(first_content)), + resource.TestCheckResourceAttr("data.onepassword_item.test", "section.0.file.1.id", expectedItem.Files[1].ID), + resource.TestCheckResourceAttr("data.onepassword_item.test", "section.0.file.1.name", expectedItem.Files[1].Name), + resource.TestCheckResourceAttr("data.onepassword_item.test", "section.0.file.1.content_base64", base64.StdEncoding.EncodeToString(second_content)), + ), + }, + }, + }) +} + func testAccItemDataSourceConfig(vault, uuid string) string { return fmt.Sprintf(` data "onepassword_item" "test" { diff --git a/internal/provider/onepassword_item_resource_test.go b/internal/provider/onepassword_item_resource_test.go index 60a4975b..b996e4f0 100644 --- a/internal/provider/onepassword_item_resource_test.go +++ b/internal/provider/onepassword_item_resource_test.go @@ -2,6 +2,7 @@ package provider import ( "fmt" + "regexp" "strings" "testing" @@ -155,12 +156,34 @@ func TestAccItemResourceWithSections(t *testing.T) { }) } +func TestAccItemResourceDocument(t *testing.T) { + expectedItem := generateDocumentItem() + expectedVault := op.Vault{ + ID: expectedItem.Vault.ID, + Name: "VaultName", + Description: "This vault will be retrieved for testing", + } + + testServer := setupTestServer(expectedItem, expectedVault, t) + defer testServer.Close() + + resource.UnitTest(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccProviderConfig(testServer.URL) + testAccDocumentResourceConfig(expectedItem), + ExpectError: regexp.MustCompile("Invalid Attribute Value Match"), + }, + }, + }) +} + func testAccDataBaseResourceConfig(expectedItem *op.Item) string { return fmt.Sprintf(` data "onepassword_vault" "acceptance-tests" { uuid = "%s" -} +} resource "onepassword_item" "test-database" { vault = data.onepassword_vault.acceptance-tests.uuid title = "%s" @@ -179,7 +202,7 @@ func testAccPasswordResourceConfig(expectedItem *op.Item) string { data "onepassword_vault" "acceptance-tests" { uuid = "%s" -} +} resource "onepassword_item" "test-database" { vault = data.onepassword_vault.acceptance-tests.uuid title = "%s" @@ -221,6 +244,19 @@ EOT }`, expectedItem.Vault.ID, expectedItem.Title, strings.ToLower(string(expectedItem.Category)), strings.TrimSuffix(expectedItem.Fields[0].Value, "\n")) } +func testAccDocumentResourceConfig(expectedItem *op.Item) string { + return fmt.Sprintf(` + +data "onepassword_vault" "acceptance-tests" { + uuid = "%s" +} +resource "onepassword_item" "test-document" { + vault = data.onepassword_vault.acceptance-tests.uuid + title = "%s" + category = "%s" +}`, expectedItem.Vault.ID, expectedItem.Title, strings.ToLower(string(expectedItem.Category))) +} + func testAccResourceWithSectionsConfig(expectedItem *op.Item) string { return fmt.Sprintf(` diff --git a/internal/provider/test_http_server.go b/internal/provider/test_http_server.go index 9dad7c65..71a46e05 100644 --- a/internal/provider/test_http_server.go +++ b/internal/provider/test_http_server.go @@ -6,6 +6,9 @@ import ( "io" "net/http" "net/http/httptest" + "regexp" + "slices" + "strings" "testing" "github.com/1Password/connect-sdk-go/onepassword" @@ -18,6 +21,16 @@ func setupTestServer(expectedItem *onepassword.Item, expectedVault onepassword.V t.Errorf("error marshaling item for testing: %s", err) } + files := expectedItem.Files + var fileBytes [][]byte + for _, file := range files { + c, err := file.Content() + if err != nil { + t.Errorf("error getting file content: %s", err) + } + fileBytes = append(fileBytes, c) + } + vaultBytes, err := json.Marshal(expectedVault) if err != nil { t.Errorf("error marshaling vault for testing: %s", err) @@ -30,6 +43,7 @@ func setupTestServer(expectedItem *onepassword.Item, expectedVault onepassword.V } return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + filePath := regexp.MustCompile("/v1/vaults/[a-z0-9]*/items/[a-z0-9]*/files/[a-z0-9]*/content") if r.Method == http.MethodGet { if r.URL.String() == fmt.Sprintf("/v1/vaults/%s/items/%s", expectedItem.Vault.ID, expectedItem.ID) { // Mock returning an item specified by uuid @@ -53,6 +67,19 @@ func setupTestServer(expectedItem *onepassword.Item, expectedVault onepassword.V if err != nil { t.Errorf("error writing body: %s", err) } + } else if filePath.MatchString(r.URL.String()) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("1Password-Connect-Version", "1.3.0") // must be >= 1.3.0 + i := slices.IndexFunc(files, func(f *onepassword.File) bool { + return f.ID == strings.Split(r.URL.Path, "/")[7] + }) + if i == -1 { + t.Errorf("file not found") + } + _, err := w.Write(fileBytes[i]) + if err != nil { + t.Errorf("error writing body: %s", err) + } } else { t.Errorf("Unexpected request: %s Consider adding this endpoint to the test server", r.URL.String()) } diff --git a/internal/provider/test_utils.go b/internal/provider/test_utils.go index ed721a1f..22446c75 100644 --- a/internal/provider/test_utils.go +++ b/internal/provider/test_utils.go @@ -1,6 +1,10 @@ package provider -import "github.com/1Password/connect-sdk-go/onepassword" +import ( + "fmt" + + "github.com/1Password/connect-sdk-go/onepassword" +) func generateBaseItem() onepassword.Item { item := onepassword.Item{} @@ -80,6 +84,51 @@ notes return &item } +func generateDocumentItem() *onepassword.Item { + item := generateBaseItem() + item.Category = onepassword.Document + item.Files = []*onepassword.File{ + { + ID: "ascii", + Name: "ascii", + ContentPath: fmt.Sprintf("/v1/vaults/%s/items/%s/files/%s/content", item.Vault.ID, item.ID, "ascii"), + }, + { + ID: "binary", + Name: "binary", + ContentPath: fmt.Sprintf("/v1/vaults/%s/items/%s/files/%s/content", item.Vault.ID, item.ID, "binary"), + }, + } + item.Files[0].SetContent([]byte("ascii")) + item.Files[1].SetContent([]byte{0xDE, 0xAD, 0xBE, 0xEF}) + + return &item +} + +func generateLoginItemWithFiles() *onepassword.Item { + item := generateItemWithSections() + item.Category = onepassword.Login + section := item.Sections[0] + item.Files = []*onepassword.File{ + { + ID: "ascii", + Name: "ascii", + Section: section, + ContentPath: fmt.Sprintf("/v1/vaults/%s/items/%s/files/%s/content", item.Vault.ID, item.ID, "ascii"), + }, + { + ID: "binary", + Name: "binary", + Section: section, + ContentPath: fmt.Sprintf("/v1/vaults/%s/items/%s/files/%s/content", item.Vault.ID, item.ID, "binary"), + }, + } + item.Files[0].SetContent([]byte("ascii")) + item.Files[1].SetContent([]byte{0xDE, 0xAD, 0xBE, 0xEF}) + + return item +} + func generateDatabaseFields() []*onepassword.ItemField { fields := []*onepassword.ItemField{ {