From cb1775c4ad35cf9eb6b6db7b4d66f2b9b76a7f2e Mon Sep 17 00:00:00 2001 From: Simon Dahlbacka Date: Fri, 17 May 2024 16:15:23 +0300 Subject: [PATCH] Re-implement support for files/documents using the plugin framework --- internal/onepassword/cli/op.go | 14 +++++ internal/onepassword/client.go | 1 + .../onepassword/connect/connect_client.go | 4 ++ internal/provider/const.go | 8 +++ .../provider/onepassword_item_data_source.go | 47 ++++++++++++++++ .../onepassword_item_data_source_test.go | 45 ++++++++++++++++ .../provider/onepassword_item_resource.go | 2 + .../onepassword_item_resource_test.go | 53 ++++++++++++++++--- internal/provider/test_http_server.go | 26 +++++++++ internal/provider/test_utils.go | 27 +++++++++- 10 files changed, 220 insertions(+), 7 deletions(-) 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..fb162702 100644 --- a/internal/provider/const.go +++ b/internal/provider/const.go @@ -30,6 +30,13 @@ const ( sectionLabelDescription = "The label for the section." sectionFieldsDescription = "A list of custom fields in the section." + filesDescription = "A list of files attached to the item." + fileDescription = "A file attached to the item." + fileIDDescription = "A UUID for 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." fieldLabelDescription = "The label for the field." @@ -57,6 +64,7 @@ var ( strings.ToLower(string(op.Password)), strings.ToLower(string(op.Database)), strings.ToLower(string(op.SecureNote)), + strings.ToLower(string(op.Document)), } fieldPurposes = []string{ diff --git a/internal/provider/onepassword_item_data_source.go b/internal/provider/onepassword_item_data_source.go index 3f6a6733..17ca1205 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,6 +48,14 @@ 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 { @@ -187,6 +196,31 @@ func (d *OnePasswordItemDataSource) Schema(ctx context.Context, req datasource.S }, }, }, + "file": schema.ListNestedBlock{ + MarkdownDescription: filesDescription, + NestedObject: 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, + }, + }, + }, + }, }, } } @@ -298,6 +332,19 @@ func (d *OnePasswordItemDataSource) Read(ctx context.Context, req datasource.Rea } } + for _, f := range item.Files { + 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..58633780 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,50 @@ 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 testAccItemDataSourceConfig(vault, uuid string) string { return fmt.Sprintf(` data "onepassword_item" "test" { diff --git a/internal/provider/onepassword_item_resource.go b/internal/provider/onepassword_item_resource.go index 79682010..5266bab4 100644 --- a/internal/provider/onepassword_item_resource.go +++ b/internal/provider/onepassword_item_resource.go @@ -167,6 +167,8 @@ func (r *OnePasswordItemResource) Schema(ctx context.Context, req resource.Schem Default: stringdefault.StaticString("login"), Validators: []validator.String{ stringvalidator.OneOfCaseInsensitive(categories...), + // connect api does not support creating document category + stringvalidator.NoneOf("document"), }, PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), diff --git a/internal/provider/onepassword_item_resource_test.go b/internal/provider/onepassword_item_resource_test.go index 60a4975b..e817a2df 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,13 +156,40 @@ 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), + // ConfigPlanChecks: resource.ConfigPlanChecks{ + // PreApply: []plancheck.PlanCheck{ + + // plancheck.ExpectUnknownOutputValue("test"), + // }, + // }, + 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" { + data "onepassword_vault" "acceptance-tests" { + uuid = "%s" + } + resource "onepassword_item" "test-database" { vault = data.onepassword_vault.acceptance-tests.uuid title = "%s" category = "%s" @@ -179,7 +207,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 +249,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..9cf2d960 100644 --- a/internal/provider/test_http_server.go +++ b/internal/provider/test_http_server.go @@ -6,6 +6,8 @@ import ( "io" "net/http" "net/http/httptest" + "slices" + "strings" "testing" "github.com/1Password/connect-sdk-go/onepassword" @@ -18,6 +20,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) @@ -53,6 +65,20 @@ func setupTestServer(expectedItem *onepassword.Item, expectedVault onepassword.V if err != nil { t.Errorf("error writing body: %s", err) } + } else if r.URL.String() == fmt.Sprintf("/v1/vaults/%s/items/%s/files/%s/content", expectedItem.Vault.ID, expectedItem.ID, files[0].ID) || + r.URL.String() == fmt.Sprintf("/v1/vaults/%s/items/%s/files/%s/content", expectedItem.Vault.ID, expectedItem.ID, files[1].ID) { + 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..d8d43203 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,27 @@ 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 generateDatabaseFields() []*onepassword.ItemField { fields := []*onepassword.ItemField{ {