Skip to content

Commit

Permalink
support creating attachments from file
Browse files Browse the repository at this point in the history
  • Loading branch information
maxlaverse committed Nov 11, 2024
1 parent 059c70d commit 25d5e35
Show file tree
Hide file tree
Showing 10 changed files with 193 additions and 25 deletions.
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
}
`
}

0 comments on commit 25d5e35

Please sign in to comment.