Skip to content

Commit ee7b688

Browse files
authored
support creating attachments from content (#187)
1 parent 059c70d commit ee7b688

File tree

10 files changed

+193
-25
lines changed

10 files changed

+193
-25
lines changed

docs/resources/attachment.md

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,20 @@ resource "bitwarden_item_login" "vpn_credentials" {
1818
username = "admin"
1919
}
2020
21-
resource "bitwarden_attachment" "vpn_config" {
21+
resource "bitwarden_attachment" "vpn_config_from_content" {
22+
// NOTE: Only works when the experimental embedded client support is enabled
23+
file_name = "vpn-config.txt"
24+
content = jsonencode({
25+
domain : "laverse.net",
26+
persistence : {
27+
enabled : true,
28+
}
29+
})
30+
31+
item_id = bitwarden_item_login.vpn_credentials.id
32+
}
33+
34+
resource "bitwarden_attachment" "vpn_config_from_file" {
2235
file = "vpn-config.txt"
2336
item_id = bitwarden_item_login.vpn_credentials.id
2437
}
@@ -29,12 +42,16 @@ resource "bitwarden_attachment" "vpn_config" {
2942

3043
### Required
3144

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

35-
### Read-Only
47+
### Optional
3648

49+
- `content` (String) Path to the content of the attachment.
50+
- `file` (String) Path to the content of the attachment.
3751
- `file_name` (String) File name
52+
53+
### Read-Only
54+
3855
- `id` (String) Identifier.
3956
- `size` (String) Size in bytes
4057
- `size_name` (String) Size as string

docs/resources/project.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ Manages a Project.
1414

1515
```terraform
1616
resource "bitwarden_project" "example" {
17-
name = "Example Project"
17+
name = "Example Project"
1818
}
1919
```
2020

examples/resources/bitwarden_attachment/resource.tf

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,20 @@ resource "bitwarden_item_login" "vpn_credentials" {
33
username = "admin"
44
}
55

6-
resource "bitwarden_attachment" "vpn_config" {
6+
resource "bitwarden_attachment" "vpn_config_from_content" {
7+
// NOTE: Only works when the experimental embedded client support is enabled
8+
file_name = "vpn-config.txt"
9+
content = jsonencode({
10+
domain : "laverse.net",
11+
persistence : {
12+
enabled : true,
13+
}
14+
})
15+
16+
item_id = bitwarden_item_login.vpn_credentials.id
17+
}
18+
19+
resource "bitwarden_attachment" "vpn_config_from_file" {
720
file = "vpn-config.txt"
821
item_id = bitwarden_item_login.vpn_credentials.id
9-
}
22+
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
resource "bitwarden_project" "example" {
2-
name = "Example Project"
2+
name = "Example Project"
33
}

internal/bitwarden/bwcli/password_manager.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ import (
1313
)
1414

1515
type PasswordManagerClient interface {
16-
CreateAttachment(ctx context.Context, itemId, filePath string) (*models.Object, error)
16+
CreateAttachmentFromFile(ctx context.Context, itemId, filePath string) (*models.Object, error)
17+
CreateAttachmentFromContent(ctx context.Context, itemId, filename string, content []byte) (*models.Object, error)
1718
CreateObject(context.Context, models.Object) (*models.Object, error)
1819
EditObject(context.Context, models.Object) (*models.Object, error)
1920
GetAttachment(ctx context.Context, itemId, attachmentId string) ([]byte, error)
@@ -114,7 +115,11 @@ func (c *client) CreateObject(ctx context.Context, obj models.Object) (*models.O
114115
return &obj, nil
115116
}
116117

117-
func (c *client) CreateAttachment(ctx context.Context, itemId string, filePath string) (*models.Object, error) {
118+
func (c *client) CreateAttachmentFromContent(ctx context.Context, itemId, filename string, content []byte) (*models.Object, error) {
119+
return nil, fmt.Errorf("creating attachments from content is only supported by the embedded client")
120+
}
121+
122+
func (c *client) CreateAttachmentFromFile(ctx context.Context, itemId string, filePath string) (*models.Object, error) {
118123
out, err := c.cmdWithSession("create", string(models.ObjectTypeAttachment), "--itemid", itemId, "--file", filePath).Run(ctx)
119124
if err != nil {
120125
return nil, err

internal/bitwarden/client.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ const (
1111
)
1212

1313
type PasswordManager interface {
14-
CreateAttachment(ctx context.Context, itemId, filePath string) (*models.Object, error)
14+
CreateAttachmentFromContent(ctx context.Context, itemId, filename string, content []byte) (*models.Object, error)
15+
CreateAttachmentFromFile(ctx context.Context, itemId, filePath string) (*models.Object, error)
1516
CreateObject(context.Context, models.Object) (*models.Object, error)
1617
DeleteAttachment(ctx context.Context, itemId, attachmentId string) error
1718
DeleteObject(context.Context, models.Object) error

internal/bitwarden/embedded/password_manager_webapi.go

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ type PasswordManagerClient interface {
2020
BaseVault
2121
CreateObject(ctx context.Context, obj models.Object) (*models.Object, error)
2222
CreateOrganization(ctx context.Context, organizationName, organizationLabel, billingEmail string) (string, error)
23-
CreateAttachment(ctx context.Context, itemId, filePath string) (*models.Object, error)
23+
CreateAttachmentFromContent(ctx context.Context, itemId, filename string, content []byte) (*models.Object, error)
24+
CreateAttachmentFromFile(ctx context.Context, itemId, filePath string) (*models.Object, error)
2425
DeleteAttachment(ctx context.Context, itemId, attachmentId string) error
2526
DeleteObject(ctx context.Context, obj models.Object) error
2627
EditObject(ctx context.Context, obj models.Object) (*models.Object, error)
@@ -109,15 +110,32 @@ type webAPIVault struct {
109110
serverURL string
110111
}
111112

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

117+
return v.createAttachment(ctx, itemId, filename, content)
118+
}
119+
120+
func (v *webAPIVault) CreateAttachmentFromFile(ctx context.Context, itemId, filePath string) (*models.Object, error) {
121+
v.vaultOperationMutex.Lock()
122+
defer v.vaultOperationMutex.Unlock()
123+
124+
filename := filepath.Base(filePath)
125+
data, err := os.ReadFile(filePath)
126+
if err != nil {
127+
return nil, fmt.Errorf("error reading attachment file: %w", err)
128+
}
129+
130+
return v.createAttachment(ctx, itemId, filename, data)
131+
}
132+
133+
func (v *webAPIVault) createAttachment(ctx context.Context, itemId, filename string, content []byte) (*models.Object, error) {
116134
if !v.objectsLoaded() {
117135
return nil, models.ErrVaultLocked
118136
}
119137

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

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

682-
data, err := os.ReadFile(filePath)
683-
if err != nil {
684-
return nil, nil, fmt.Errorf("error reading file: %w", err)
685-
}
686-
687700
attachmentKey, err := keybuilder.CreateObjectKey()
688701
if err != nil {
689702
return nil, nil, err
690703
}
691704

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

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

712725
req := webapi.AttachmentRequestData{
713-
FileName: filename,
726+
FileName: encFilename,
714727
FileSize: len(encDataBuffer),
715728
Key: dataKeyEncrypted,
716729
}

internal/provider/attachment.go

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,17 @@ func resourceCreateAttachment(ctx context.Context, d *schema.ResourceData, bwCli
2222
return diag.FromErr(err)
2323
}
2424

25-
filePath := d.Get(attributeAttachmentFile).(string)
26-
obj, err := bwClient.CreateAttachment(ctx, itemId, filePath)
25+
var obj *models.Object
26+
filePath, fileSpecified := d.GetOk(attributeAttachmentFile)
27+
content, contentSpecified := d.GetOk(attributeAttachmentContent)
28+
fileName, fileNameSpecified := d.GetOk(attributeAttachmentFileName)
29+
if fileSpecified {
30+
obj, err = bwClient.CreateAttachmentFromFile(ctx, itemId, filePath.(string))
31+
} else if contentSpecified && fileNameSpecified {
32+
obj, err = bwClient.CreateAttachmentFromContent(ctx, itemId, fileName.(string), []byte(content.(string)))
33+
} else {
34+
err = errors.New("BUG: either file or content&file_name should be specified")
35+
}
2736
if err != nil {
2837
return diag.FromErr(err)
2938
}
@@ -155,3 +164,14 @@ func fileSha1Sum(filepath string) (string, error) {
155164

156165
return hex.EncodeToString(outputChecksum[:]), nil
157166
}
167+
168+
func contentSha1Sum(content string) (string, error) {
169+
hash := sha1.New()
170+
_, err := hash.Write([]byte(content))
171+
if err != nil {
172+
return "", err
173+
}
174+
outputChecksum := hash.Sum(nil)
175+
176+
return hex.EncodeToString(outputChecksum[:]), nil
177+
}

internal/provider/resource_attachment.go

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,35 @@ func resourceAttachment() *schema.Resource {
1515
resourceAttachmentSchema[attributeAttachmentFile] = &schema.Schema{
1616
Description: descriptionItemAttachmentFile,
1717
Type: schema.TypeString,
18-
Required: true,
18+
Optional: true,
19+
ConflictsWith: []string{attributeAttachmentContent},
20+
AtLeastOneOf: []string{attributeAttachmentContent},
1921
ForceNew: true,
2022
ValidateDiagFunc: fileHashComputable,
2123
StateFunc: fileHash,
2224
}
25+
resourceAttachmentSchema[attributeAttachmentContent] = &schema.Schema{
26+
Description: descriptionItemAttachmentFile,
27+
Type: schema.TypeString,
28+
Optional: true,
29+
RequiredWith: []string{attributeAttachmentContent},
30+
ConflictsWith: []string{attributeAttachmentFile},
31+
AtLeastOneOf: []string{attributeAttachmentFile},
32+
ForceNew: true,
33+
StateFunc: contentHash,
34+
}
35+
resourceAttachmentSchema[attributeAttachmentFileName] = &schema.Schema{
36+
Description: descriptionItemAttachmentFileName,
37+
Type: schema.TypeString,
38+
RequiredWith: []string{attributeAttachmentContent},
39+
ConflictsWith: []string{attributeAttachmentFile},
40+
ComputedWhen: []string{attributeAttachmentFile},
41+
AtLeastOneOf: []string{attributeAttachmentFile},
42+
ForceNew: true,
43+
Optional: true,
44+
Computed: true,
45+
}
46+
2347
resourceAttachmentSchema[attributeAttachmentItemID] = &schema.Schema{
2448
Description: descriptionItemIdentifier,
2549
Type: schema.TypeString,
@@ -49,6 +73,11 @@ func resourceImportAttachment(ctx context.Context, d *schema.ResourceData, meta
4973
return []*schema.ResourceData{d}, nil
5074
}
5175

76+
func contentHash(val interface{}) string {
77+
hash, _ := contentSha1Sum(val.(string))
78+
return hash
79+
}
80+
5281
func fileHashComputable(val interface{}, _ cty.Path) diag.Diagnostics {
5382
_, err := fileSha1Sum(val.(string))
5483
if err != nil {

internal/provider/resource_attachment_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ func TestAccResourceAttachment(t *testing.T) {
2424
Config: tfConfigPasswordManagerProvider() + tfConfigResourceAttachment("non-existent"),
2525
ExpectError: regexp.MustCompile("no such file or directory"),
2626
},
27+
// Attachments created from File
2728
{
2829
ResourceName: resourceName,
2930
Config: tfConfigPasswordManagerProvider() + tfConfigResourceAttachment("fixtures/attachment1.txt"),
@@ -37,6 +38,40 @@ func TestAccResourceAttachment(t *testing.T) {
3738
checkAttachmentMatches(resourceName, ""),
3839
),
3940
},
41+
// Attachments created from Content
42+
{
43+
ResourceName: resourceName,
44+
Config: tfConfigPasswordManagerProvider(),
45+
SkipFunc: func() (bool, error) { return !useEmbeddedClient, nil },
46+
},
47+
{
48+
ResourceName: resourceName,
49+
Config: tfConfigPasswordManagerProvider() + tfConfigResourceAttachmentFromContentWithFilename(),
50+
SkipFunc: func() (bool, error) { return !useEmbeddedClient, nil },
51+
ExpectError: regexp.MustCompile("\"file_name\": one of"),
52+
},
53+
{
54+
ResourceName: resourceName,
55+
Config: tfConfigPasswordManagerProvider() + tfConfigResourceAttachmentFromContent("Hello, I'm a text attachment"),
56+
SkipFunc: func() (bool, error) { return !useEmbeddedClient, nil },
57+
Check: resource.ComposeTestCheckFunc(
58+
resource.TestCheckResourceAttr(
59+
resourceName, attributeAttachmentContent, contentHash("Hello, I'm a text attachment"),
60+
),
61+
resource.TestMatchResourceAttr(
62+
resourceName, attributeAttachmentItemID, regexp.MustCompile(regExpId),
63+
),
64+
checkAttachmentMatches(resourceName, ""),
65+
),
66+
},
67+
{
68+
ResourceName: resourceName,
69+
Config: tfConfigPasswordManagerProvider() + tfConfigResourceAttachmentFromContent("Hello, I'm a text attachment") + tfConfigDataAttachment(),
70+
SkipFunc: func() (bool, error) { return !useEmbeddedClient, nil },
71+
Check: resource.TestMatchResourceAttr(
72+
"data.bitwarden_attachment.foo_data", attributeAttachmentContent, regexp.MustCompile(`^Hello, I'm a text attachment$`),
73+
),
74+
},
4075
{
4176
ResourceName: resourceName,
4277
ImportStateIdFunc: attachmentImportID(resourceName, "bitwarden_item_login.foo"),
@@ -232,3 +267,38 @@ resource "bitwarden_attachment" "foo" {
232267
}
233268
`
234269
}
270+
271+
func tfConfigResourceAttachmentFromContent(content string) string {
272+
return `
273+
resource "bitwarden_item_login" "foo" {
274+
provider = bitwarden
275+
276+
name = "foo"
277+
}
278+
279+
resource "bitwarden_attachment" "foo" {
280+
provider = bitwarden
281+
282+
content = "` + content + `"
283+
file_name = "attachment1.txt"
284+
item_id = bitwarden_item_login.foo.id
285+
}
286+
`
287+
}
288+
289+
func tfConfigResourceAttachmentFromContentWithFilename() string {
290+
return `
291+
resource "bitwarden_item_login" "foo" {
292+
provider = bitwarden
293+
294+
name = "foo"
295+
}
296+
297+
resource "bitwarden_attachment" "foo" {
298+
provider = bitwarden
299+
300+
content = "not-used"
301+
item_id = bitwarden_item_login.foo.id
302+
}
303+
`
304+
}

0 commit comments

Comments
 (0)