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 creating attachments directly from content #187

Merged
merged 1 commit into from
Nov 13, 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
23 changes: 20 additions & 3 deletions docs/resources/attachment.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,20 @@ resource "bitwarden_item_login" "vpn_credentials" {
username = "admin"
}

resource "bitwarden_attachment" "vpn_config" {
resource "bitwarden_attachment" "vpn_config_from_content" {
// NOTE: Only works when the experimental embedded client support is enabled
file_name = "vpn-config.txt"
content = jsonencode({
domain : "laverse.net",
persistence : {
enabled : true,
}
})

item_id = bitwarden_item_login.vpn_credentials.id
}

resource "bitwarden_attachment" "vpn_config_from_file" {
file = "vpn-config.txt"
item_id = bitwarden_item_login.vpn_credentials.id
}
Expand All @@ -29,12 +42,16 @@ resource "bitwarden_attachment" "vpn_config" {

### Required

- `file` (String) Path to the content of the attachment.
- `item_id` (String) Identifier of the item the attachment belongs to

### Read-Only
### Optional

- `content` (String) Path to the content of the attachment.
- `file` (String) Path to the content of the attachment.
- `file_name` (String) File name

### Read-Only

- `id` (String) Identifier.
- `size` (String) Size in bytes
- `size_name` (String) Size as string
Expand Down
2 changes: 1 addition & 1 deletion docs/resources/project.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Manages a Project.

```terraform
resource "bitwarden_project" "example" {
name = "Example Project"
name = "Example Project"
}
```

Expand Down
17 changes: 15 additions & 2 deletions examples/resources/bitwarden_attachment/resource.tf
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,20 @@ resource "bitwarden_item_login" "vpn_credentials" {
username = "admin"
}

resource "bitwarden_attachment" "vpn_config" {
resource "bitwarden_attachment" "vpn_config_from_content" {
// NOTE: Only works when the experimental embedded client support is enabled
file_name = "vpn-config.txt"
content = jsonencode({
domain : "laverse.net",
persistence : {
enabled : true,
}
})

item_id = bitwarden_item_login.vpn_credentials.id
}

resource "bitwarden_attachment" "vpn_config_from_file" {
file = "vpn-config.txt"
item_id = bitwarden_item_login.vpn_credentials.id
}
}
2 changes: 1 addition & 1 deletion examples/resources/bitwarden_project/resource.tf
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
resource "bitwarden_project" "example" {
name = "Example Project"
name = "Example Project"
}
9 changes: 7 additions & 2 deletions internal/bitwarden/bwcli/password_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import (
)

type PasswordManagerClient interface {
CreateAttachment(ctx context.Context, itemId, filePath string) (*models.Object, error)
CreateAttachmentFromFile(ctx context.Context, itemId, filePath string) (*models.Object, error)
CreateAttachmentFromContent(ctx context.Context, itemId, filename string, content []byte) (*models.Object, error)
CreateObject(context.Context, models.Object) (*models.Object, error)
EditObject(context.Context, models.Object) (*models.Object, error)
GetAttachment(ctx context.Context, itemId, attachmentId string) ([]byte, error)
Expand Down Expand Up @@ -114,7 +115,11 @@ func (c *client) CreateObject(ctx context.Context, obj models.Object) (*models.O
return &obj, nil
}

func (c *client) CreateAttachment(ctx context.Context, itemId string, filePath string) (*models.Object, error) {
func (c *client) CreateAttachmentFromContent(ctx context.Context, itemId, filename string, content []byte) (*models.Object, error) {
return nil, fmt.Errorf("creating attachments from content is only supported by the embedded client")
}

func (c *client) CreateAttachmentFromFile(ctx context.Context, itemId string, filePath string) (*models.Object, error) {
out, err := c.cmdWithSession("create", string(models.ObjectTypeAttachment), "--itemid", itemId, "--file", filePath).Run(ctx)
if err != nil {
return nil, err
Expand Down
3 changes: 2 additions & 1 deletion internal/bitwarden/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ const (
)

type PasswordManager interface {
CreateAttachment(ctx context.Context, itemId, filePath string) (*models.Object, error)
CreateAttachmentFromContent(ctx context.Context, itemId, filename string, content []byte) (*models.Object, error)
CreateAttachmentFromFile(ctx context.Context, itemId, filePath string) (*models.Object, error)
CreateObject(context.Context, models.Object) (*models.Object, error)
DeleteAttachment(ctx context.Context, itemId, attachmentId string) error
DeleteObject(context.Context, models.Object) error
Expand Down
37 changes: 25 additions & 12 deletions internal/bitwarden/embedded/password_manager_webapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ type PasswordManagerClient interface {
BaseVault
CreateObject(ctx context.Context, obj models.Object) (*models.Object, error)
CreateOrganization(ctx context.Context, organizationName, organizationLabel, billingEmail string) (string, error)
CreateAttachment(ctx context.Context, itemId, filePath string) (*models.Object, error)
CreateAttachmentFromContent(ctx context.Context, itemId, filename string, content []byte) (*models.Object, error)
CreateAttachmentFromFile(ctx context.Context, itemId, filePath string) (*models.Object, error)
DeleteAttachment(ctx context.Context, itemId, attachmentId string) error
DeleteObject(ctx context.Context, obj models.Object) error
EditObject(ctx context.Context, obj models.Object) (*models.Object, error)
Expand Down Expand Up @@ -109,15 +110,32 @@ type webAPIVault struct {
serverURL string
}

func (v *webAPIVault) CreateAttachment(ctx context.Context, itemId, filePath string) (*models.Object, error) {
func (v *webAPIVault) CreateAttachmentFromContent(ctx context.Context, itemId, filename string, content []byte) (*models.Object, error) {
v.vaultOperationMutex.Lock()
defer v.vaultOperationMutex.Unlock()

return v.createAttachment(ctx, itemId, filename, content)
}

func (v *webAPIVault) CreateAttachmentFromFile(ctx context.Context, itemId, filePath string) (*models.Object, error) {
v.vaultOperationMutex.Lock()
defer v.vaultOperationMutex.Unlock()

filename := filepath.Base(filePath)
data, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("error reading attachment file: %w", err)
}

return v.createAttachment(ctx, itemId, filename, data)
}

func (v *webAPIVault) createAttachment(ctx context.Context, itemId, filename string, content []byte) (*models.Object, error) {
if !v.objectsLoaded() {
return nil, models.ErrVaultLocked
}

req, data, err := v.prepareAttachmentCreationRequest(ctx, itemId, filePath)
req, data, err := v.prepareAttachmentCreationRequest(ctx, itemId, filename, content)
if err != nil {
return nil, fmt.Errorf("error preparing attachment creation request: %w", err)
}
Expand Down Expand Up @@ -666,7 +684,7 @@ func (v *webAPIVault) continueLoginWithTokens(ctx context.Context, tokenResp web
return v.sync(ctx)
}

func (v *webAPIVault) prepareAttachmentCreationRequest(ctx context.Context, itemId, filePath string) (*webapi.AttachmentRequestData, []byte, error) {
func (v *webAPIVault) prepareAttachmentCreationRequest(ctx context.Context, itemId, filename string, content []byte) (*webapi.AttachmentRequestData, []byte, error) {
// NOTE: We don't Sync() to get the latest version of Object before adding an attachment to it, because we
// assume the Object's key can't change.
originalObj, err := v.getObject(ctx, models.Object{ID: itemId, Object: models.ObjectTypeItem})
Expand All @@ -679,17 +697,12 @@ func (v *webAPIVault) prepareAttachmentCreationRequest(ctx context.Context, item
return nil, nil, fmt.Errorf("error get cipher key while creating attachment: %w", err)
}

data, err := os.ReadFile(filePath)
if err != nil {
return nil, nil, fmt.Errorf("error reading file: %w", err)
}

attachmentKey, err := keybuilder.CreateObjectKey()
if err != nil {
return nil, nil, err
}

encData, err := crypto.Encrypt(data, *attachmentKey)
encData, err := crypto.Encrypt(content, *attachmentKey)
if err != nil {
return nil, nil, fmt.Errorf("error encrypting data: %w", err)
}
Expand All @@ -699,7 +712,7 @@ func (v *webAPIVault) prepareAttachmentCreationRequest(ctx context.Context, item
return nil, nil, fmt.Errorf("error getting encrypted buffer: %w", err)
}

filename, err := crypto.EncryptAsString([]byte(filepath.Base(filePath)), *objectKey)
encFilename, err := crypto.EncryptAsString([]byte(filename), *objectKey)
if err != nil {
return nil, nil, fmt.Errorf("error encrypting filename: %w", err)
}
Expand All @@ -710,7 +723,7 @@ func (v *webAPIVault) prepareAttachmentCreationRequest(ctx context.Context, item
}

req := webapi.AttachmentRequestData{
FileName: filename,
FileName: encFilename,
FileSize: len(encDataBuffer),
Key: dataKeyEncrypted,
}
Expand Down
24 changes: 22 additions & 2 deletions internal/provider/attachment.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,17 @@ func resourceCreateAttachment(ctx context.Context, d *schema.ResourceData, bwCli
return diag.FromErr(err)
}

filePath := d.Get(attributeAttachmentFile).(string)
obj, err := bwClient.CreateAttachment(ctx, itemId, filePath)
var obj *models.Object
filePath, fileSpecified := d.GetOk(attributeAttachmentFile)
content, contentSpecified := d.GetOk(attributeAttachmentContent)
fileName, fileNameSpecified := d.GetOk(attributeAttachmentFileName)
if fileSpecified {
obj, err = bwClient.CreateAttachmentFromFile(ctx, itemId, filePath.(string))
} else if contentSpecified && fileNameSpecified {
obj, err = bwClient.CreateAttachmentFromContent(ctx, itemId, fileName.(string), []byte(content.(string)))
} else {
err = errors.New("BUG: either file or content&file_name should be specified")
}
if err != nil {
return diag.FromErr(err)
}
Expand Down Expand Up @@ -155,3 +164,14 @@ func fileSha1Sum(filepath string) (string, error) {

return hex.EncodeToString(outputChecksum[:]), nil
}

func contentSha1Sum(content string) (string, error) {
hash := sha1.New()
_, err := hash.Write([]byte(content))
if err != nil {
return "", err
}
outputChecksum := hash.Sum(nil)

return hex.EncodeToString(outputChecksum[:]), nil
}
31 changes: 30 additions & 1 deletion internal/provider/resource_attachment.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,35 @@ func resourceAttachment() *schema.Resource {
resourceAttachmentSchema[attributeAttachmentFile] = &schema.Schema{
Description: descriptionItemAttachmentFile,
Type: schema.TypeString,
Required: true,
Optional: true,
ConflictsWith: []string{attributeAttachmentContent},
AtLeastOneOf: []string{attributeAttachmentContent},
ForceNew: true,
ValidateDiagFunc: fileHashComputable,
StateFunc: fileHash,
}
resourceAttachmentSchema[attributeAttachmentContent] = &schema.Schema{
Description: descriptionItemAttachmentFile,
Type: schema.TypeString,
Optional: true,
RequiredWith: []string{attributeAttachmentContent},
ConflictsWith: []string{attributeAttachmentFile},
AtLeastOneOf: []string{attributeAttachmentFile},
ForceNew: true,
StateFunc: contentHash,
}
resourceAttachmentSchema[attributeAttachmentFileName] = &schema.Schema{
Description: descriptionItemAttachmentFileName,
Type: schema.TypeString,
RequiredWith: []string{attributeAttachmentContent},
ConflictsWith: []string{attributeAttachmentFile},
ComputedWhen: []string{attributeAttachmentFile},
AtLeastOneOf: []string{attributeAttachmentFile},
ForceNew: true,
Optional: true,
Computed: true,
}

resourceAttachmentSchema[attributeAttachmentItemID] = &schema.Schema{
Description: descriptionItemIdentifier,
Type: schema.TypeString,
Expand Down Expand Up @@ -49,6 +73,11 @@ func resourceImportAttachment(ctx context.Context, d *schema.ResourceData, meta
return []*schema.ResourceData{d}, nil
}

func contentHash(val interface{}) string {
hash, _ := contentSha1Sum(val.(string))
return hash
}

func fileHashComputable(val interface{}, _ cty.Path) diag.Diagnostics {
_, err := fileSha1Sum(val.(string))
if err != nil {
Expand Down
70 changes: 70 additions & 0 deletions internal/provider/resource_attachment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ func TestAccResourceAttachment(t *testing.T) {
Config: tfConfigPasswordManagerProvider() + tfConfigResourceAttachment("non-existent"),
ExpectError: regexp.MustCompile("no such file or directory"),
},
// Attachments created from File
{
ResourceName: resourceName,
Config: tfConfigPasswordManagerProvider() + tfConfigResourceAttachment("fixtures/attachment1.txt"),
Expand All @@ -37,6 +38,40 @@ func TestAccResourceAttachment(t *testing.T) {
checkAttachmentMatches(resourceName, ""),
),
},
// Attachments created from Content
{
ResourceName: resourceName,
Config: tfConfigPasswordManagerProvider(),
SkipFunc: func() (bool, error) { return !useEmbeddedClient, nil },
},
{
ResourceName: resourceName,
Config: tfConfigPasswordManagerProvider() + tfConfigResourceAttachmentFromContentWithFilename(),
SkipFunc: func() (bool, error) { return !useEmbeddedClient, nil },
ExpectError: regexp.MustCompile("\"file_name\": one of"),
},
{
ResourceName: resourceName,
Config: tfConfigPasswordManagerProvider() + tfConfigResourceAttachmentFromContent("Hello, I'm a text attachment"),
SkipFunc: func() (bool, error) { return !useEmbeddedClient, nil },
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr(
resourceName, attributeAttachmentContent, contentHash("Hello, I'm a text attachment"),
),
resource.TestMatchResourceAttr(
resourceName, attributeAttachmentItemID, regexp.MustCompile(regExpId),
),
checkAttachmentMatches(resourceName, ""),
),
},
{
ResourceName: resourceName,
Config: tfConfigPasswordManagerProvider() + tfConfigResourceAttachmentFromContent("Hello, I'm a text attachment") + tfConfigDataAttachment(),
SkipFunc: func() (bool, error) { return !useEmbeddedClient, nil },
Check: resource.TestMatchResourceAttr(
"data.bitwarden_attachment.foo_data", attributeAttachmentContent, regexp.MustCompile(`^Hello, I'm a text attachment$`),
),
},
{
ResourceName: resourceName,
ImportStateIdFunc: attachmentImportID(resourceName, "bitwarden_item_login.foo"),
Expand Down Expand Up @@ -232,3 +267,38 @@ resource "bitwarden_attachment" "foo" {
}
`
}

func tfConfigResourceAttachmentFromContent(content string) string {
return `
resource "bitwarden_item_login" "foo" {
provider = bitwarden

name = "foo"
}

resource "bitwarden_attachment" "foo" {
provider = bitwarden

content = "` + content + `"
file_name = "attachment1.txt"
item_id = bitwarden_item_login.foo.id
}
`
}

func tfConfigResourceAttachmentFromContentWithFilename() string {
return `
resource "bitwarden_item_login" "foo" {
provider = bitwarden

name = "foo"
}

resource "bitwarden_attachment" "foo" {
provider = bitwarden

content = "not-used"
item_id = bitwarden_item_login.foo.id
}
`
}
Loading