diff --git a/docs/data-sources/org_collection.md b/docs/data-sources/org_collection.md index 9764e30..adb5b22 100644 --- a/docs/data-sources/org_collection.md +++ b/docs/data-sources/org_collection.md @@ -31,6 +31,39 @@ resource "bitwarden_item_login" "administrative_user" { organization_id = data.bitwarden_organization.terraform.id collection_ids = [data.bitwarden_org_collection.terraform.id] } + +# Example of usage with ACLs: +locals { + emails =[ + "regular-user-1@example.com", + "regular-user-2@example.com", + ] +} + +data "bitwarden_org_member" "regular_users" { + organization_id = data.bitwarden_organization.terraform.id + count = length(local.emails) + email = local.emails[count.index] +} + + +resource "bitwarden_org_collection" "my_collection" { + organization_id = data.bitwarden_organization.terraform.id + name = "my-collection" + + + dynamic "member" { + for_each = data.bitwarden_org_member.regular_users + content { + id = member.value.id + read_only = true + } + } + + member { + id = data.bitwarden_org_member.john.id + } +} ``` @@ -47,7 +80,7 @@ resource "bitwarden_item_login" "administrative_user" { ### Read-Only -- `member` (List of Object) [Experimental] Member of a collection. (see [below for nested schema](#nestedatt--member)) +- `member` (Set of Object) [Experimental] Member of a collection. (see [below for nested schema](#nestedatt--member)) - `name` (String) Name. @@ -56,6 +89,5 @@ resource "bitwarden_item_login" "administrative_user" { Read-Only: - `hide_passwords` (Boolean) -- `org_member_id` (String) +- `id` (String) - `read_only` (Boolean) -- `user_email` (String) diff --git a/docs/data-sources/org_member.md b/docs/data-sources/org_member.md new file mode 100644 index 0000000..8b96009 --- /dev/null +++ b/docs/data-sources/org_member.md @@ -0,0 +1,43 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "bitwarden_org_member Data Source - terraform-provider-bitwarden" +subcategory: "" +description: |- + Use this data source to get information on an existing organization member. +--- + +# bitwarden_org_member (Data Source) + +Use this data source to get information on an existing organization member. + +## Example Usage + +```terraform +data "bitwarden_organization" "terraform" { + search = "Terraform" +} +data "bitwarden_org_member" "john" { + email = "john@example.com" + organization_id = data.bitwarden_organization.terraform.id +} + + +# Example of usage of the data source: +# See org_collection +``` + + +## Schema + +### Required + +- `organization_id` (String) Identifier of the organization. + +### Optional + +- `email` (String) User email. +- `id` (String) Identifier. + +### Read-Only + +- `name` (String) Name. diff --git a/docs/resources/attachment.md b/docs/resources/attachment.md index f1c8225..b637d1d 100644 --- a/docs/resources/attachment.md +++ b/docs/resources/attachment.md @@ -22,7 +22,7 @@ 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", + domain : "example.com", persistence : { enabled : true, } diff --git a/docs/resources/org_collection.md b/docs/resources/org_collection.md index 3e592d0..853450d 100644 --- a/docs/resources/org_collection.md +++ b/docs/resources/org_collection.md @@ -43,7 +43,7 @@ resource "bitwarden_org_collection" "generated" { ### Optional - `id` (String) Identifier. -- `member` (Block List) [Experimental] Member of a collection. (see [below for nested schema](#nestedblock--member)) +- `member` (Block Set) [Experimental] Member of a collection. (see [below for nested schema](#nestedblock--member)) ### Read-Only @@ -53,17 +53,13 @@ resource "bitwarden_org_collection" "generated" { Required: -- `user_email` (String) [Experimental] User email. +- `id` (String) Identifier. Optional: - `hide_passwords` (Boolean) [Experimental] Hide passwords. - `read_only` (Boolean) [Experimental] Read/Write permissions. -Read-Only: - -- `org_member_id` (String) [Experimental] Identifier of the member in the organization. - ## Import Import is supported using the following syntax: diff --git a/examples/data-sources/bitwarden_org_collection/data-source.tf b/examples/data-sources/bitwarden_org_collection/data-source.tf index a731fe2..88c6f09 100644 --- a/examples/data-sources/bitwarden_org_collection/data-source.tf +++ b/examples/data-sources/bitwarden_org_collection/data-source.tf @@ -17,3 +17,36 @@ resource "bitwarden_item_login" "administrative_user" { organization_id = data.bitwarden_organization.terraform.id collection_ids = [data.bitwarden_org_collection.terraform.id] } + +# Example of usage with ACLs: +locals { + emails =[ + "regular-user-1@example.com", + "regular-user-2@example.com", + ] +} + +data "bitwarden_org_member" "regular_users" { + organization_id = data.bitwarden_organization.terraform.id + count = length(local.emails) + email = local.emails[count.index] +} + + +resource "bitwarden_org_collection" "my_collection" { + organization_id = data.bitwarden_organization.terraform.id + name = "my-collection" + + + dynamic "member" { + for_each = data.bitwarden_org_member.regular_users + content { + id = member.value.id + read_only = true + } + } + + member { + id = data.bitwarden_org_member.john.id + } +} diff --git a/examples/data-sources/bitwarden_org_member/data-source.tf b/examples/data-sources/bitwarden_org_member/data-source.tf new file mode 100644 index 0000000..5e8ce4b --- /dev/null +++ b/examples/data-sources/bitwarden_org_member/data-source.tf @@ -0,0 +1,12 @@ + +data "bitwarden_organization" "terraform" { + search = "Terraform" +} +data "bitwarden_org_member" "john" { + email = "john@example.com" + organization_id = data.bitwarden_organization.terraform.id +} + + +# Example of usage of the data source: +# See org_collection diff --git a/examples/resources/bitwarden_attachment/resource.tf b/examples/resources/bitwarden_attachment/resource.tf index a8a1ecd..ccd065e 100644 --- a/examples/resources/bitwarden_attachment/resource.tf +++ b/examples/resources/bitwarden_attachment/resource.tf @@ -7,7 +7,7 @@ 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", + domain : "example.com", persistence : { enabled : true, } diff --git a/internal/bitwarden/bwcli/password_manager.go b/internal/bitwarden/bwcli/password_manager.go index 73c6b3d..31be033 100644 --- a/internal/bitwarden/bwcli/password_manager.go +++ b/internal/bitwarden/bwcli/password_manager.go @@ -28,11 +28,13 @@ type PasswordManagerClient interface { FindFolder(ctx context.Context, options ...bitwarden.ListObjectsOption) (*models.Folder, error) FindItem(ctx context.Context, options ...bitwarden.ListObjectsOption) (*models.Item, error) FindOrganization(ctx context.Context, options ...bitwarden.ListObjectsOption) (*models.Organization, error) + FindOrganizationMember(ctx context.Context, options ...bitwarden.ListObjectsOption) (*models.OrgMember, error) FindOrganizationCollection(ctx context.Context, options ...bitwarden.ListObjectsOption) (*models.OrgCollection, error) GetAttachment(ctx context.Context, itemId, attachmentId string) ([]byte, error) GetFolder(context.Context, models.Folder) (*models.Folder, error) GetItem(context.Context, models.Item) (*models.Item, error) GetOrganization(context.Context, models.Organization) (*models.Organization, error) + GetOrganizationMember(context.Context, models.OrgMember) (*models.OrgMember, error) GetOrganizationCollection(ctx context.Context, collection models.OrgCollection) (*models.OrgCollection, error) GetSessionKey() string HasSessionKey() bool @@ -237,6 +239,10 @@ func (c *client) GetOrganization(ctx context.Context, obj models.Organization) ( return getObject(ctx, c, obj, obj.Object, obj.ID) } +func (c *client) GetOrganizationMember(ctx context.Context, obj models.OrgMember) (*models.OrgMember, error) { + return nil, fmt.Errorf("getting organization members is only supported by the embedded client") +} + func (c *client) GetOrganizationCollection(ctx context.Context, obj models.OrgCollection) (*models.OrgCollection, error) { return getObject(ctx, c, obj, obj.Object, obj.ID) } @@ -296,6 +302,10 @@ func (c *client) FindOrganization(ctx context.Context, options ...bitwarden.List return findGenericObject[models.Organization](ctx, c, models.ObjectTypeOrganization, options...) } +func (c *client) FindOrganizationMember(ctx context.Context, options ...bitwarden.ListObjectsOption) (*models.OrgMember, error) { + return nil, fmt.Errorf("find organization members is only supported by the embedded client") +} + func (c *client) FindOrganizationCollection(ctx context.Context, options ...bitwarden.ListObjectsOption) (*models.OrgCollection, error) { return findGenericObject[models.OrgCollection](ctx, c, models.ObjectTypeOrgCollection, options...) } diff --git a/internal/bitwarden/client.go b/internal/bitwarden/client.go index 843cd32..fd7cdc8 100644 --- a/internal/bitwarden/client.go +++ b/internal/bitwarden/client.go @@ -26,11 +26,13 @@ type PasswordManager interface { FindFolder(ctx context.Context, options ...ListObjectsOption) (*models.Folder, error) FindItem(ctx context.Context, options ...ListObjectsOption) (*models.Item, error) FindOrganization(ctx context.Context, options ...ListObjectsOption) (*models.Organization, error) + FindOrganizationMember(ctx context.Context, options ...ListObjectsOption) (*models.OrgMember, error) FindOrganizationCollection(ctx context.Context, options ...ListObjectsOption) (*models.OrgCollection, error) GetAttachment(ctx context.Context, itemId, attachmentId string) ([]byte, error) GetFolder(context.Context, models.Folder) (*models.Folder, error) GetItem(context.Context, models.Item) (*models.Item, error) GetOrganization(context.Context, models.Organization) (*models.Organization, error) + GetOrganizationMember(context.Context, models.OrgMember) (*models.OrgMember, error) GetOrganizationCollection(ctx context.Context, collection models.OrgCollection) (*models.OrgCollection, error) LoginWithAPIKey(ctx context.Context, password, clientId, clientSecret string) error LoginWithPassword(ctx context.Context, username, password string) error diff --git a/internal/bitwarden/embedded/member_mapping.go b/internal/bitwarden/embedded/member_mapping.go deleted file mode 100644 index e313f08..0000000 --- a/internal/bitwarden/embedded/member_mapping.go +++ /dev/null @@ -1,66 +0,0 @@ -package embedded - -import "fmt" - -type OrganizationMember struct { - Email string - Id string - UserId string -} - -type MemberMapping map[string][]OrganizationMember - -func NewMemberMapping() MemberMapping { - return make(map[string][]OrganizationMember) -} - -func (m MemberMapping) AddMember(orgId string, member OrganizationMember) { - m[orgId] = append(m[orgId], member) -} - -func (m MemberMapping) FindMemberByID(orgId, memberId string) (*OrganizationMember, error) { - if len(memberId) == 0 { - return nil, fmt.Errorf("BUG: FindMemberByID() called with empty memberId") - } - - members, ok := m[orgId] - if ok { - for _, user := range members { - if user.Id == memberId { - return &user, nil - } - } - } - - return nil, fmt.Errorf("no member found with email '%s' in organization '%s' (org exists: %t)", orgId, memberId, ok) -} - -func (m MemberMapping) FindMemberByEmail(orgId, userEmail string) (*OrganizationMember, error) { - if len(userEmail) == 0 { - return nil, fmt.Errorf("BUG: FindMemberByEmail() called with empty userEmail") - } - - members, ok := m[orgId] - if ok { - for _, user := range members { - if user.Email == userEmail { - return &user, nil - } - } - } - - return nil, fmt.Errorf("no member found with email '%s' in organization '%s' (org exists: %t)", userEmail, orgId, ok) -} - -func (m MemberMapping) ForgetOrganization(orgId string) { - delete(m, orgId) -} - -func (m MemberMapping) OrganizationInitialized(orgId string) bool { - _, ok := m[orgId] - return ok -} - -func (m MemberMapping) ResetOrganization(orgId string) { - m[orgId] = []OrganizationMember{} -} diff --git a/internal/bitwarden/embedded/org_member_store.go b/internal/bitwarden/embedded/org_member_store.go new file mode 100644 index 0000000..9f31471 --- /dev/null +++ b/internal/bitwarden/embedded/org_member_store.go @@ -0,0 +1,91 @@ +package embedded + +import ( + "fmt" + + "github.com/maxlaverse/terraform-provider-bitwarden/internal/bitwarden/models" + "github.com/maxlaverse/terraform-provider-bitwarden/internal/bitwarden/webapi" +) + +type OrgMemberStore map[string][]models.OrgMember + +func NewOrgMemberStore() OrgMemberStore { + return make(map[string][]models.OrgMember) +} + +func (m OrgMemberStore) AddMember(orgId string, member models.OrgMember) { + m[orgId] = append(m[orgId], member) +} + +func (m OrgMemberStore) FindMemberByID(orgId, memberId string) (*models.OrgMember, error) { + if len(memberId) == 0 { + return nil, fmt.Errorf("BUG: FindMemberByID() called with empty memberId") + } + + members, ok := m[orgId] + if ok { + for _, user := range members { + if user.ID == memberId { + return &user, nil + } + } + } + + return nil, fmt.Errorf("no member found with email '%s' in organization '%s' (org exists: %t)", orgId, memberId, ok) +} + +func (m OrgMemberStore) FindMemberByEmail(orgId, userEmail string) (*models.OrgMember, error) { + if len(userEmail) == 0 { + return nil, fmt.Errorf("BUG: FindMemberByEmail() called with empty userEmail") + } + + members, ok := m[orgId] + if ok { + for _, user := range members { + if user.Email == userEmail { + return &user, nil + } + } + } + + return nil, fmt.Errorf("no member found with email '%s' in organization '%s' (org exists: %t)", userEmail, orgId, ok) +} + +func (m OrgMemberStore) FindMemberByName(orgId, userName string) (*models.OrgMember, error) { + if len(userName) == 0 { + return nil, fmt.Errorf("BUG: FindMemberByName() called with empty userName") + } + + members, ok := m[orgId] + if ok { + for _, user := range members { + if user.Name == userName { + return &user, nil + } + } + } + + return nil, fmt.Errorf("no member found with name '%s' in organization '%s' (org exists: %t)", userName, orgId, ok) +} + +func (m OrgMemberStore) ForgetOrganization(orgId string) { + delete(m, orgId) +} + +func (m OrgMemberStore) OrganizationInitialized(orgId string) bool { + _, ok := m[orgId] + return ok +} + +func (m OrgMemberStore) LoadMembers(orgId string, users []webapi.OrganizationUserDetails) { + m[orgId] = []models.OrgMember{} + for _, user := range users { + m[orgId] = append(m[orgId], models.OrgMember{ + ID: user.Id, + Email: user.Email, + Name: user.Name, + OrganizationId: orgId, + UserId: user.UserId, + }) + } +} diff --git a/internal/bitwarden/embedded/password_manager_base.go b/internal/bitwarden/embedded/password_manager_base.go index b91ac13..825f5a2 100644 --- a/internal/bitwarden/embedded/password_manager_base.go +++ b/internal/bitwarden/embedded/password_manager_base.go @@ -57,7 +57,7 @@ type baseVault struct { // organizationMembers stores the members of an organization alongside // their user information. This allows us to reference members by their // email for example. - organizationMembers MemberMapping + organizationMembers OrgMemberStore } func (v *baseVault) GetItem(ctx context.Context, obj models.Item) (*models.Item, error) { @@ -178,7 +178,7 @@ func (v *baseVault) clearObjectStore(ctx context.Context) { } v.collectionDetailsLoadedForOrg = make(map[string]bool) v.objectStore = make(map[string]interface{}) - v.organizationMembers = NewMemberMapping() + v.organizationMembers = NewOrgMemberStore() } func (v *baseVault) deleteObjectFromStore(ctx context.Context, obj any) { @@ -186,56 +186,6 @@ func (v *baseVault) deleteObjectFromStore(ctx context.Context, obj any) { delete(v.objectStore, objKey(obj)) } -func (v *webAPIVault) enhanceOrgCollectionMembers(ctx context.Context, orgCol models.OrgCollection) error { - err := v.ensureUsersLoadedForOrg(ctx, orgCol.OrganizationID) - if err != nil { - return fmt.Errorf("error loading users of organization '%s': %w", orgCol.OrganizationID, err) - } - - for k, user := range orgCol.Users { - if len(user.UserEmail) == 0 { - u, err := v.organizationMembers.FindMemberByID(orgCol.OrganizationID, user.OrgMemberId) - if err != nil { - return fmt.Errorf("no details found for member with id '%s' in organization '%s'", user.OrgMemberId, orgCol.OrganizationID) - } - orgCol.Users[k].UserEmail = u.Email - continue - } - - if len(user.OrgMemberId) == 0 { - u, err := v.organizationMembers.FindMemberByEmail(orgCol.OrganizationID, user.UserEmail) - if err != nil { - return fmt.Errorf("no details found for member with email '%s' in organization '%s'", user.UserEmail, orgCol.OrganizationID) - } - orgCol.Users[k].OrgMemberId = u.Id - continue - } - } - - emailsFound := make(map[string]int) - idFounds := make(map[string]int) - for _, user := range orgCol.Users { - if len(user.UserEmail) > 0 { - emailsFound[user.UserEmail]++ - } - if len(user.OrgMemberId) > 0 { - idFounds[user.OrgMemberId]++ - } - } - for email, count := range emailsFound { - if count > 1 { - return fmt.Errorf("BUG: enhanceOrgCollectionMembers, duplicate email '%s' found", email) - } - } - for id, count := range idFounds { - if count > 1 { - return fmt.Errorf("BUG: enhanceOrgCollectionMembers, duplicate id '%s' found", id) - } - } - - return nil -} - func (v *baseVault) objectsLoaded() bool { return v.objectStore != nil } @@ -305,7 +255,7 @@ func decryptOrgCollection(obj webapi.Collection, secret AccountSecrets) (*models for _, u := range obj.Users { users = append(users, models.OrgCollectionMember{ HidePasswords: u.HidePasswords, - OrgMemberId: u.Id, + Id: u.Id, ReadOnly: u.ReadOnly, }) } @@ -501,12 +451,9 @@ func encryptOrgCollection(_ context.Context, obj models.OrgCollection, secret Ac users := make([]webapi.CollectionUser, len(obj.Users)) for k, orgMember := range obj.Users { - if len(orgMember.OrgMemberId) == 0 { - return nil, fmt.Errorf("member id is empty") - } users[k] = webapi.CollectionUser{ HidePasswords: orgMember.HidePasswords, - Id: orgMember.OrgMemberId, + Id: orgMember.Id, ReadOnly: orgMember.ReadOnly, } } @@ -525,12 +472,6 @@ func encryptOrgCollection(_ context.Context, obj models.OrgCollection, secret Ac return nil, fmt.Errorf("error decrypting collection for verification: %w", err) } - // Emails are lost when converting from webapi.Collection to models.OrgCollection. - // We need to diff them out for the comparison to work. - for k := range actualObj.Users { - actualObj.Users[k].UserEmail = obj.Users[k].UserEmail - } - err = compareObjects(obj, *actualObj) if err != nil { return nil, fmt.Errorf("error verifying collection after encryption: %w", err) diff --git a/internal/bitwarden/embedded/password_manager_webapi.go b/internal/bitwarden/embedded/password_manager_webapi.go index dbf863f..f8d53d7 100644 --- a/internal/bitwarden/embedded/password_manager_webapi.go +++ b/internal/bitwarden/embedded/password_manager_webapi.go @@ -37,10 +37,12 @@ type PasswordManagerClient interface { EditFolder(ctx context.Context, obj models.Folder) (*models.Folder, error) EditItem(ctx context.Context, obj models.Item) (*models.Item, error) EditOrganizationCollection(ctx context.Context, collection models.OrgCollection) (*models.OrgCollection, error) + FindOrganizationMember(ctx context.Context, options ...bitwarden.ListObjectsOption) (*models.OrgMember, error) FindOrganizationCollection(ctx context.Context, options ...bitwarden.ListObjectsOption) (*models.OrgCollection, error) GetAPIKey(ctx context.Context, username, password string) (*models.ApiKey, error) GetAttachment(ctx context.Context, itemId, attachmentId string) ([]byte, error) GetOrganization(context.Context, models.Organization) (*models.Organization, error) + GetOrganizationMember(context.Context, models.OrgMember) (*models.OrgMember, error) GetOrganizationCollection(ctx context.Context, collection models.OrgCollection) (*models.OrgCollection, error) InviteUser(ctx context.Context, orgId, userEmail string, memberRoleType models.OrgMemberRoleType) error LoginWithAPIKey(ctx context.Context, password, clientId, clientSecret string) error @@ -129,10 +131,16 @@ type webAPIVault struct { } func (v *webAPIVault) ConfirmInvite(ctx context.Context, orgId, userEmail string) (string, error) { - v.vaultOperationMutex.RLock() - defer v.vaultOperationMutex.RUnlock() + // Write lock is needed since we eventually load the organization members. + v.vaultOperationMutex.Lock() + defer v.vaultOperationMutex.Unlock() - orgUser, err := v.findOrganizationUser(ctx, orgId, userEmail) + err := v.ensureUsersLoadedForOrg(ctx, orgId) + if err != nil { + return "", fmt.Errorf("error loading users of organization '%s': %w", orgId, err) + } + + orgUser, err := v.organizationMembers.FindMemberByEmail(orgId, userEmail) if err != nil { return "", fmt.Errorf("error getting organization user : %w", err) } @@ -147,7 +155,7 @@ func (v *webAPIVault) ConfirmInvite(ctx context.Context, orgId, userEmail string return "", fmt.Errorf("error rsa encrypting organization key: %w", err) } - return orgUser.Id, v.client.ConfirmOrganizationUser(ctx, orgId, orgUser.Id, string(orgKey)) + return orgUser.ID, v.client.ConfirmOrganizationUser(ctx, orgId, orgUser.ID, string(orgKey)) } func (v *webAPIVault) CreateAttachmentFromContent(ctx context.Context, itemId, filename string, content []byte) (*models.Item, error) { @@ -323,19 +331,19 @@ func (v *webAPIVault) ensureUsersLoadedForOrg(ctx context.Context, orgId string) return fmt.Errorf("error getting organization users: %w", err) } - v.organizationMembers.ResetOrganization(orgId) - for _, u := range orgUsers { - v.organizationMembers.AddMember(orgId, OrganizationMember{ - Email: u.Email, - Id: u.Id, - UserId: u.UserId, - }) - } + v.organizationMembers.LoadMembers(orgId, orgUsers) return nil } func (v *webAPIVault) CreateOrganizationCollection(ctx context.Context, obj models.OrgCollection) (*models.OrgCollection, error) { + // ValidateFunc is not supported on TypeSet, which means we can't check for + // duplicate during Schema validation. Doing it here instead. + err := checkForDuplicateMembers(obj.Users) + if err != nil { + return nil, err + } + v.vaultOperationMutex.Lock() defer v.vaultOperationMutex.Unlock() @@ -344,12 +352,6 @@ func (v *webAPIVault) CreateOrganizationCollection(ctx context.Context, obj mode } manageMembership := len(obj.Users) > 0 - if manageMembership { - err := v.enhanceOrgCollectionMembers(ctx, obj) - if err != nil { - return nil, fmt.Errorf("error completing member information for creation: %w", err) - } - } encObj, err := encryptOrgCollection(ctx, obj, v.loginAccount.Secrets, v.verifyObjectEncryption) if err != nil { @@ -405,6 +407,13 @@ func (v *webAPIVault) CreateOrganizationCollection(ctx context.Context, obj mode } func (v *webAPIVault) EditOrganizationCollection(ctx context.Context, obj models.OrgCollection) (*models.OrgCollection, error) { + // ValidateFunc is not supported on TypeSet, which means we can't check for + // duplicate during Schema validation. Doing it here instead. + err := checkForDuplicateMembers(obj.Users) + if err != nil { + return nil, err + } + v.vaultOperationMutex.Lock() defer v.vaultOperationMutex.Unlock() @@ -414,18 +423,13 @@ func (v *webAPIVault) EditOrganizationCollection(ctx context.Context, obj models // When editing a collection, we need to ensure we have enough permissions // to manage the collection's memberships if members were specified. - currentObj, err := getObject(v.objectStore, obj) + currentObj, err := v.getOrganizationCollection(ctx, obj) if err != nil { - return nil, fmt.Errorf("error getting collection prior to edition: %w", err) + return nil, fmt.Errorf("error getting collection prior to edition: %w %+v", err, obj) } manageMembership := currentObj.Manage - if manageMembership { - err := v.enhanceOrgCollectionMembers(ctx, obj) - if err != nil { - return nil, fmt.Errorf("error completing member information for edition: %w", err) - } - } else if len(obj.Users) > 0 { + if !manageMembership && len(obj.Users) > 0 { return nil, fmt.Errorf("error editing collection: you need to have the Manage permission to edit memberships") } @@ -824,10 +828,28 @@ func (v *webAPIVault) GetAttachment(ctx context.Context, itemId, attachmentId st return []byte(decryptedBody), nil } +func (v *webAPIVault) GetOrganizationMember(ctx context.Context, obj models.OrgMember) (*models.OrgMember, error) { + // Write lock is needed since we eventually load the organization members. + v.vaultOperationMutex.Lock() + defer v.vaultOperationMutex.Unlock() + + err := v.ensureUsersLoadedForOrg(ctx, obj.OrganizationId) + if err != nil { + return nil, fmt.Errorf("error loading users of organization '%s': %w", obj.OrganizationId, err) + } + + return v.organizationMembers.FindMemberByID(obj.OrganizationId, obj.ID) +} + func (v *webAPIVault) GetOrganizationCollection(ctx context.Context, obj models.OrgCollection) (*models.OrgCollection, error) { - v.vaultOperationMutex.RLock() - defer v.vaultOperationMutex.RUnlock() + // Write lock is needed since we eventually load collections. + v.vaultOperationMutex.Lock() + defer v.vaultOperationMutex.Unlock() + return v.getOrganizationCollection(ctx, obj) +} + +func (v *webAPIVault) getOrganizationCollection(ctx context.Context, obj models.OrgCollection) (*models.OrgCollection, error) { err := v.ensureCollectionLoadedForOrg(ctx, obj.OrganizationID) if err != nil { // We do our best to load the collection details, but we don't want to fail if we can't. @@ -856,15 +878,36 @@ func (v *webAPIVault) InviteUser(ctx context.Context, orgId, userEmail string, m return v.client.InviteUser(ctx, orgId, req) } -func (v *webAPIVault) FindOrganizationCollection(ctx context.Context, options ...bitwarden.ListObjectsOption) (*models.OrgCollection, error) { - v.vaultOperationMutex.RLock() - defer v.vaultOperationMutex.RUnlock() +func (v *webAPIVault) FindOrganizationMember(ctx context.Context, options ...bitwarden.ListObjectsOption) (*models.OrgMember, error) { + filter := bitwarden.ListObjectsOptionsToFilterOptions(options...) + if !filter.IsValid() { + return nil, fmt.Errorf("invalid filter options") + } + + // Write lock is needed since we eventually load the organization members. + v.vaultOperationMutex.Lock() + defer v.vaultOperationMutex.Unlock() + + orgId := filter.OrganizationFilter + userEmail := filter.SearchFilter + err := v.ensureUsersLoadedForOrg(ctx, orgId) + if err != nil { + return nil, fmt.Errorf("error loading users of organization '%s': %w", orgId, err) + } + return v.organizationMembers.FindMemberByEmail(orgId, userEmail) +} + +func (v *webAPIVault) FindOrganizationCollection(ctx context.Context, options ...bitwarden.ListObjectsOption) (*models.OrgCollection, error) { filter := bitwarden.ListObjectsOptionsToFilterOptions(options...) if !filter.IsValid() { return nil, fmt.Errorf("invalid filter options") } + // Write lock is needed since we eventually load collections. + v.vaultOperationMutex.Lock() + defer v.vaultOperationMutex.Unlock() + err := v.ensureCollectionLoadedForOrg(ctx, filter.OrganizationFilter) if err != nil { // We do our best to load the collection details, but we don't want to fail if we can't. @@ -1043,15 +1086,6 @@ func (v *webAPIVault) continueLoginWithTokens(ctx context.Context, tokenResp web return v.sync(ctx) } -func (v *webAPIVault) findOrganizationUser(ctx context.Context, orgId, userEmail string) (*OrganizationMember, error) { - err := v.ensureUsersLoadedForOrg(ctx, orgId) - if err != nil { - return nil, fmt.Errorf("error loading users of organization '%s': %w", orgId, err) - } - - return v.organizationMembers.FindMemberByEmail(orgId, userEmail) -} - func (v *webAPIVault) getUserPublicKey(ctx context.Context, userId string) (*rsa.PublicKey, error) { userPublicKey, err := v.client.GetUserPublicKey(ctx, userId) if err != nil { @@ -1092,11 +1126,6 @@ func (v *webAPIVault) ensureCollectionLoadedForOrg(ctx context.Context, orgId st return fmt.Errorf("error decrypting collection: %w", err) } - err = v.enhanceOrgCollectionMembers(ctx, *orgCol) - if err != nil { - return fmt.Errorf("error completing member information: %w", err) - } - v.storeObject(ctx, *orgCol) } return nil @@ -1229,3 +1258,17 @@ func loadOrganizationSecrets(accountSecrets AccountSecrets, organizations []weba } return nil } + +func checkForDuplicateMembers(users []models.OrgCollectionMember) error { + uniqueMembers := make(map[string]int) + for _, member := range users { + uniqueMembers[member.Id]++ + } + + for memberId, count := range uniqueMembers { + if count > 1 { + return fmt.Errorf("member ID '%s' was specified twice", memberId) + } + } + return nil +} diff --git a/internal/bitwarden/models/password_manager.go b/internal/bitwarden/models/password_manager.go index d990874..1248508 100644 --- a/internal/bitwarden/models/password_manager.go +++ b/internal/bitwarden/models/password_manager.go @@ -74,6 +74,7 @@ const ( ObjectTypeList ObjectType = "list" // encapsulates collection list response ObjectTypeCollectionDetails ObjectType = "collectionDetails" // collection listed in sync ObjectTypeCollection ObjectType = "collection" // used when refetching collections + ObjectTypeOrgMember ObjectType = "org-member" ObjectTypeProfile ObjectType = "profile" ObjectTypeSync ObjectType = "sync" ObjectTypeProfileOrganization ObjectType = "profileOrganization" // organization under profile @@ -83,7 +84,6 @@ const ( ObjectProject ObjectType = "project" ObjectSecret ObjectType = "secret" ObjectUserKey ObjectType = "userKey" - ObjectOrgMember ObjectType = "org-member" ) const ( @@ -187,10 +187,17 @@ type Attachment struct { } type OrgCollectionMember struct { - HidePasswords bool - OrgMemberId string - ReadOnly bool - UserEmail string + HidePasswords bool `json:"hidePasswords"` + Id string `json:"id"` + ReadOnly bool `json:"readOnly"` +} + +type OrgMember struct { + OrganizationId string + ID string + Email string + Name string + UserId string } type OrgCollection struct { diff --git a/internal/provider/data_source_org_member.go b/internal/provider/data_source_org_member.go new file mode 100644 index 0000000..3c18680 --- /dev/null +++ b/internal/provider/data_source_org_member.go @@ -0,0 +1,14 @@ +package provider + +import ( + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/maxlaverse/terraform-provider-bitwarden/internal/schema_definition" +) + +func dataSourceOrgMember() *schema.Resource { + return &schema.Resource{ + Description: "Use this data source to get information on an existing organization member.", + ReadContext: withPasswordManager(opOrganizationMemberRead), + Schema: schema_definition.OrgMemberSchema(), + } +} diff --git a/internal/provider/data_source_org_member_test.go b/internal/provider/data_source_org_member_test.go new file mode 100644 index 0000000..dba458a --- /dev/null +++ b/internal/provider/data_source_org_member_test.go @@ -0,0 +1,121 @@ +package provider + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +func TestAccDataSourceOrgMemberAttribute(t *testing.T) { + ensureVaultwardenConfigured(t) + + if !useEmbeddedClient { + t.Skip("Skipping test because official client doesn't support org members") + } + + resource.Test(t, resource.TestCase{ + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: tfConfigPasswordManagerProvider() + tfConfigDataOrgMembers(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "data.bitwarden_org_member.org_owner", "id", testAccountEmailOrgOwnerInTestOrgUserId, + ), + resource.TestCheckResourceAttr( + "data.bitwarden_org_member.org_owner", "organization_id", testOrganizationID, + ), + resource.TestCheckResourceAttr( + "data.bitwarden_org_member.org_owner", "email", testAccountEmailOrgOwner, + ), + resource.TestCheckResourceAttr( + "data.bitwarden_org_member.org_owner", "name", fmt.Sprintf("test-%s", testUniqueIdentifier), + ), + resource.TestCheckResourceAttr( + "data.bitwarden_org_member.org_admin", "id", testAccountEmailOrgAdminInTestOrgUserId, + ), + resource.TestCheckResourceAttr( + "data.bitwarden_org_member.org_admin", "organization_id", testOrganizationID, + ), + resource.TestCheckResourceAttr( + "data.bitwarden_org_member.org_admin", "email", testAccountEmailOrgAdmin, + ), + resource.TestCheckResourceAttr( + "data.bitwarden_org_member.org_manager", "id", testAccountEmailOrgManagerInTestOrgUserId, + ), + resource.TestCheckResourceAttr( + "data.bitwarden_org_member.org_manager", "organization_id", testOrganizationID, + ), + resource.TestCheckResourceAttr( + "data.bitwarden_org_member.org_manager", "email", testAccountEmailOrgManager, + ), + resource.TestCheckResourceAttr( + "data.bitwarden_org_member.org_user", "id", testAccountEmailOrgUserInTestOrgUserId, + ), + resource.TestCheckResourceAttr( + "data.bitwarden_org_member.org_user", "organization_id", testOrganizationID, + ), + resource.TestCheckResourceAttr( + "data.bitwarden_org_member.org_user", "email", testAccountEmailOrgUser, + ), + resource.TestCheckResourceAttr( + "data.bitwarden_org_member.org_user_by_id", "id", testAccountEmailOrgUserInTestOrgUserId, + ), + resource.TestCheckResourceAttr( + "data.bitwarden_org_member.org_user_by_id", "organization_id", testOrganizationID, + ), + resource.TestCheckResourceAttr( + "data.bitwarden_org_member.org_user_by_id", "email", testAccountEmailOrgUser, + ), + ), + }, + }, + }) +} + +func tfConfigDataOrgMembers() string { + return fmt.Sprintf(` +data "bitwarden_org_member" "org_owner" { + provider = bitwarden + organization_id = "%s" + + email = "%s" +} + +data "bitwarden_org_member" "org_admin" { + provider = bitwarden + organization_id = "%s" + + email = "%s" +} + +data "bitwarden_org_member" "org_manager" { + provider = bitwarden + organization_id = "%s" + + email = "%s" +} + +data "bitwarden_org_member" "org_user" { + provider = bitwarden + organization_id = "%s" + + email = "%s" +} + +data "bitwarden_org_member" "org_user_by_id" { + provider = bitwarden + organization_id = "%s" + + id = "%s" +} + +`, + testOrganizationID, testAccountEmailOrgOwner, + testOrganizationID, testAccountEmailOrgAdmin, + testOrganizationID, testAccountEmailOrgManager, + testOrganizationID, testAccountEmailOrgUser, + testOrganizationID, testAccountEmailOrgUserInTestOrgUserId, + ) +} diff --git a/internal/provider/operation_org_member.go b/internal/provider/operation_org_member.go new file mode 100644 index 0000000..fb53439 --- /dev/null +++ b/internal/provider/operation_org_member.go @@ -0,0 +1,29 @@ +package provider + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/maxlaverse/terraform-provider-bitwarden/internal/bitwarden" + "github.com/maxlaverse/terraform-provider-bitwarden/internal/schema_definition" + "github.com/maxlaverse/terraform-provider-bitwarden/internal/transformation" +) + +func opOrganizationMemberRead(ctx context.Context, d *schema.ResourceData, bwClient bitwarden.PasswordManager) diag.Diagnostics { + d.SetId(d.Get(schema_definition.AttributeID).(string)) + if _, idProvided := d.GetOk(schema_definition.AttributeID); !idProvided { + orgId := d.Get(schema_definition.AttributeOrganizationID).(string) + + // Per schema, if the ID is not provided then the email has. + userEmail := d.Get(schema_definition.AttributeEmail).(string) + + obj, err := bwClient.FindOrganizationMember(ctx, bitwarden.WithOrganizationID(orgId), bitwarden.WithSearch(userEmail)) + if err != nil { + return diag.FromErr(err) + } + return diag.FromErr(transformation.OrganizationMemberObjectToSchema(ctx, obj, d)) + } + + return diag.FromErr(applyOperation(ctx, d, bwClient.GetOrganizationMember, transformation.OrganizationMemberToObject, transformation.OrganizationMemberObjectToSchema)) +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index bf690eb..8d14e61 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -84,9 +84,9 @@ func New(version string) func() *schema.Provider { Required: true, DefaultFunc: schema.EnvDefaultFunc("BW_URL", bitwarden.DefaultBitwardenServerURL), }, - schema_definition.AttributeEmail: { + schema_definition.AttributeProviderEmail: { Type: schema.TypeString, - Description: schema_definition.DescriptionEmail, + Description: schema_definition.DescriptionProviderEmail, Optional: true, AtLeastOneOf: []string{schema_definition.AttributeAccessToken, schema_definition.AttributeClientID, schema_definition.AttributeSessionKey}, DefaultFunc: schema.EnvDefaultFunc("BW_EMAIL", nil), @@ -126,6 +126,7 @@ func New(version string) func() *schema.Provider { "bitwarden_item_login": dataSourceItemLogin(), "bitwarden_item_secure_note": dataSourceItemSecureNote(), "bitwarden_org_collection": dataSourceOrgCollection(), + "bitwarden_org_member": dataSourceOrgMember(), "bitwarden_organization": dataSourceOrganization(), "bitwarden_project": dataSourceProject(), "bitwarden_secret": dataSourceSecret(), @@ -270,7 +271,7 @@ func ensureLoggedInCLIPasswordManager(ctx context.Context, d *schema.ResourceDat clientSecret := d.Get(schema_definition.AttributeClientSecret) return bwClient.LoginWithAPIKey(ctx, masterPassword.(string), clientID.(string), clientSecret.(string)) case LoginMethodPassword: - email := d.Get(schema_definition.AttributeEmail) + email := d.Get(schema_definition.AttributeProviderEmail) return bwClient.LoginWithPassword(ctx, email.(string), masterPassword.(string)) } @@ -305,7 +306,7 @@ func loginMethod(d *schema.ResourceData) LoginMethod { func logoutIfIdentityChanged(ctx context.Context, d *schema.ResourceData, bwClient bwcli.PasswordManagerClient, status *bwcli.Status) error { serverURL := d.Get(schema_definition.AttributeServer).(string) - email := d.Get(schema_definition.AttributeEmail).(string) + email := d.Get(schema_definition.AttributeProviderEmail).(string) if (status.Status == bwcli.StatusLocked || status.Status == bwcli.StatusUnlocked) && (!status.VaultOfUser(email) || !status.VaultFromServer(serverURL)) { status.Status = bwcli.StatusUnauthenticated @@ -434,7 +435,7 @@ func ensureLoggedInEmbeddedPasswordManager(ctx context.Context, d *schema.Resour clientSecret := d.Get(schema_definition.AttributeClientSecret) return bwClient.LoginWithAPIKey(ctx, masterPassword.(string), clientID.(string), clientSecret.(string)) case LoginMethodPassword: - email := d.Get(schema_definition.AttributeEmail) + email := d.Get(schema_definition.AttributeProviderEmail) return bwClient.LoginWithPassword(ctx, email.(string), masterPassword.(string)) } diff --git a/internal/provider/resource_org_collection_test.go b/internal/provider/resource_org_collection_test.go index 3616196..62d6937 100644 --- a/internal/provider/resource_org_collection_test.go +++ b/internal/provider/resource_org_collection_test.go @@ -80,7 +80,7 @@ func TestAccResourceOrgCollectionACLs(t *testing.T) { getObjectID(resourceName, &objectID), ), }, - // Adding one member + // 2. Adding one member { ResourceName: resourceName, Config: tfConfigPasswordManagerProvider() + tfConfigResourceOrgCollectionSingleMember("org-col-bar"), @@ -94,22 +94,18 @@ func TestAccResourceOrgCollectionACLs(t *testing.T) { resource.TestCheckResourceAttr( resourceName, "member.#", "1", ), - resource.TestCheckResourceAttr( - resourceName, "member.0.user_email", testAccountEmailOrgOwner, - ), - resource.TestMatchResourceAttr( - resourceName, fmt.Sprintf("member.0.%s", schema_definition.AttributeCollectionMemberOrgMemberId), regexp.MustCompile(regExpId), - ), - resource.TestCheckResourceAttr( - resourceName, "member.0.read_only", "false", - ), - resource.TestCheckResourceAttr( - resourceName, "member.0.hide_passwords", "false", + resource.TestCheckTypeSetElemNestedAttrs( + resourceName, "member.*", map[string]string{ + "id": testAccountEmailOrgOwnerInTestOrgUserId, + "read_only": "false", + "hide_passwords": "false", + }, ), + getObjectID(resourceName, &objectID), ), }, - // Adding a second member with permission set 1 + // 3. Adding a second member with permission set 1 { ResourceName: resourceName, Config: tfConfigPasswordManagerProvider() + tfConfigResourceOrgCollectionTwoMembers("org-col-bar", false, true), @@ -123,18 +119,16 @@ func TestAccResourceOrgCollectionACLs(t *testing.T) { resource.TestCheckResourceAttr( resourceName, "member.#", "2", ), - resource.TestCheckResourceAttr( - resourceName, "member.0.user_email", testAccountEmailOrgUser, - ), - resource.TestCheckResourceAttr( - resourceName, "member.0.read_only", "false", - ), - resource.TestCheckResourceAttr( - resourceName, "member.0.hide_passwords", "true", + resource.TestCheckTypeSetElemNestedAttrs( + resourceName, "member.*", map[string]string{ + "id": testAccountEmailOrgUserInTestOrgUserId, + "read_only": "false", + "hide_passwords": "true", + }, ), ), }, - // Changing second member to permissions set 2 + // 4. Changing second member to permissions set 2 { ResourceName: resourceName, Config: tfConfigPasswordManagerProvider() + tfConfigResourceOrgCollectionTwoMembers("org-col-bar", true, false), @@ -148,18 +142,16 @@ func TestAccResourceOrgCollectionACLs(t *testing.T) { resource.TestCheckResourceAttr( resourceName, "member.#", "2", ), - resource.TestCheckResourceAttr( - resourceName, "member.0.user_email", testAccountEmailOrgUser, - ), - resource.TestCheckResourceAttr( - resourceName, "member.0.read_only", "true", - ), - resource.TestCheckResourceAttr( - resourceName, "member.0.hide_passwords", "false", + resource.TestCheckTypeSetElemNestedAttrs( + resourceName, "member.*", map[string]string{ + "id": testAccountEmailOrgUserInTestOrgUserId, + "read_only": "true", + "hide_passwords": "false", + }, ), ), }, - // Removing permissions + // 5. Removing permissions { ResourceName: resourceName, Config: tfConfigPasswordManagerProvider() + tfConfigResourceOrgCollectionSingleMember("org-col-bar"), @@ -173,14 +165,12 @@ func TestAccResourceOrgCollectionACLs(t *testing.T) { resource.TestCheckResourceAttr( resourceName, "member.#", "1", ), - resource.TestCheckResourceAttr( - resourceName, "member.0.user_email", testAccountEmailOrgOwner, - ), - resource.TestCheckResourceAttr( - resourceName, "member.0.read_only", "false", - ), - resource.TestCheckResourceAttr( - resourceName, "member.0.hide_passwords", "false", + resource.TestCheckTypeSetElemNestedAttrs( + resourceName, "member.*", map[string]string{ + "id": testAccountEmailOrgOwnerInTestOrgUserId, + "read_only": "false", + "hide_passwords": "false", + }, ), ), }, @@ -241,16 +231,16 @@ func tfConfigResourceOrgCollectionTwoMembers(name string, readOnly, hidePassword name = "%s" member { - user_email = "%s" + id = "%s" read_only = %s hide_passwords = %s } member { - user_email = "%s" + id = "%s" } } -`, testOrganizationID, name, testAccountEmailOrgUser, strconv.FormatBool(readOnly), strconv.FormatBool(hidePasswords), testAccountEmailOrgOwner) +`, testOrganizationID, name, testAccountEmailOrgUserInTestOrgUserId, strconv.FormatBool(readOnly), strconv.FormatBool(hidePasswords), testAccountEmailOrgOwnerInTestOrgUserId) } func tfConfigResourceOrgCollectionNoMembers(name string) string { @@ -275,8 +265,8 @@ func tfConfigResourceOrgCollectionSingleMember(name string) string { name = "%s" member { - user_email = "%s" + id = "%s" } } -`, testOrganizationID, name, testAccountEmailOrgOwner) +`, testOrganizationID, name, testAccountEmailOrgOwnerInTestOrgUserId) } diff --git a/internal/schema_definition/org_collection.go b/internal/schema_definition/org_collection.go index 4c5886d..412d3bc 100644 --- a/internal/schema_definition/org_collection.go +++ b/internal/schema_definition/org_collection.go @@ -30,7 +30,7 @@ func OrgCollectionSchema(schemaType schemaTypeEnum) map[string]*schema.Schema { }, AttributeMember: { Description: DescriptionCollectionMember, - Type: schema.TypeList, + Type: schema.TypeSet, Elem: membershipElem(), Computed: schemaType == DataSource, Optional: schemaType == Resource, @@ -53,25 +53,22 @@ func OrgCollectionSchema(schemaType schemaTypeEnum) map[string]*schema.Schema { func membershipElem() *schema.Resource { return &schema.Resource{ Schema: map[string]*schema.Schema{ - AttributeCollectionMemberUserEmail: { - Description: DescriptionCollectionMemberUserEmail, + AttributeID: { + Description: DescriptionIdentifier, Type: schema.TypeString, Required: true, }, - AttributeCollectionMemberOrgMemberId: { - Description: DescriptionCollectionMemberOrgMemberId, - Type: schema.TypeString, - Computed: true, - }, AttributeCollectionMemberReadOnly: { Description: DescriptionCollectionMemberReadOnly, Type: schema.TypeBool, Optional: true, + Default: false, }, AttributeCollectionMemberHidePasswords: { Description: DescriptionCollectionMemberHidePasswords, Type: schema.TypeBool, Optional: true, + Default: false, }, }, } diff --git a/internal/schema_definition/org_member.go b/internal/schema_definition/org_member.go new file mode 100644 index 0000000..dda3184 --- /dev/null +++ b/internal/schema_definition/org_member.go @@ -0,0 +1,38 @@ +package schema_definition + +import ( + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func OrgMemberSchema() map[string]*schema.Schema { + base := map[string]*schema.Schema{ + AttributeID: { + Description: DescriptionIdentifier, + Type: schema.TypeString, + Optional: true, + }, + AttributeObject: { + Description: DescriptionInternal, + Type: schema.TypeString, + Computed: true, + }, + AttributeOrganizationID: { + Description: DescriptionOrganizationID, + Type: schema.TypeString, + Required: true, + }, + AttributeEmail: { + Description: DescriptionEmail, + Type: schema.TypeString, + Optional: true, + AtLeastOneOf: []string{AttributeEmail, AttributeID}, + }, + AttributeName: { + Description: DescriptionName, + Type: schema.TypeString, + Computed: true, + }, + } + + return base +} diff --git a/internal/schema_definition/schema_attributes.go b/internal/schema_definition/schema_attributes.go index 2190c46..749779a 100644 --- a/internal/schema_definition/schema_attributes.go +++ b/internal/schema_definition/schema_attributes.go @@ -4,7 +4,6 @@ const ( // Data-source and Resource field attributes AttributeAttachments = "attachments" AttributeCollectionIDs = "collection_ids" - AttributeCollectionMemberUserEmail = "user_email" AttributeCollectionMemberOrgMemberId = "org_member_id" AttributeCollectionMemberReadOnly = "read_only" AttributeCollectionMemberHidePasswords = "hide_passwords" @@ -18,7 +17,6 @@ const ( AttributeFieldHidden = "hidden" AttributeFieldLinked = "linked" AttributeFieldText = "text" - AttributeFilterValues = "values" AttributeFolderID = "folder_id" AttributeAttachmentContent = "content" AttributeAttachmentItemID = "item_id" @@ -27,6 +25,7 @@ const ( AttributeAttachmentSizeName = "size_name" AttributeAttachmentFileName = "file_name" AttributeAttachmentURL = "url" + AttributeEmail = "email" AttributeFilterCollectionId = "filter_collection_id" AttributeFilterFolderID = "filter_folder_id" AttributeFilterOrganizationID = "filter_organization_id" @@ -57,12 +56,11 @@ const ( DescriptionAttachments = "List of item attachments." DescriptionCollectionIDs = "Identifier of the collections the item belongs to." DescriptionCollectionMember = "[Experimental] Member of a collection." - DescriptionCollectionMemberUserEmail = "[Experimental] User email." - DescriptionCollectionMemberOrgMemberId = "[Experimental] Identifier of the member in the organization." DescriptionCollectionMemberReadOnly = "[Experimental] Read/Write permissions." DescriptionCollectionMemberHidePasswords = "[Experimental] Hide passwords." DescriptionCreationDate = "Date the item was created." DescriptionDeletedDate = "Date the item was deleted." + DescriptionEmail = "User email." DescriptionFavorite = "Mark as a Favorite to have item appear at the top of your Vault in the UI." DescriptionField = "Extra fields." DescriptionFieldBoolean = "Value of a boolean field." @@ -106,7 +104,7 @@ const ( AttributeAccessToken = "access_token" AttributeClientID = "client_id" AttributeClientSecret = "client_secret" - AttributeEmail = "email" + AttributeProviderEmail = "email" AttributeMasterPassword = "master_password" AttributeServer = "server" AttributeSessionKey = "session_key" @@ -119,7 +117,7 @@ const ( DescriptionAccessToken = "Machine Account Access Token (env: `BWS_ACCESS_TOKEN`))." DescriptionClientSecret = "Client Secret (env: `BW_CLIENTSECRET`). Do not commit this information in Git unless you know what you're doing. Prefer using a Terraform `variable {}` in order to inject this value from the environment." DescriptionClientID = "Client ID (env: `BW_CLIENTID`)" - DescriptionEmail = "Login Email of the Vault (env: `BW_EMAIL`)." + DescriptionProviderEmail = "Login Email of the Vault (env: `BW_EMAIL`)." DescriptionMasterPassword = "Master password of the Vault (env: `BW_PASSWORD`). Do not commit this information in Git unless you know what you're doing. Prefer using a Terraform `variable {}` in order to inject this value from the environment." DescriptionServer = "Bitwarden Server URL (default: `https://vault.bitwarden.com`, env: `BW_URL`)." DescriptionSessionKey = "A Bitwarden Session Key (env: `BW_SESSION`)" diff --git a/internal/transformation/org_collection.go b/internal/transformation/org_collection.go index be89ac0..51d8348 100644 --- a/internal/transformation/org_collection.go +++ b/internal/transformation/org_collection.go @@ -2,6 +2,7 @@ package transformation import ( "context" + "hash/fnv" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/maxlaverse/terraform-provider-bitwarden/internal/bitwarden/models" @@ -35,13 +36,15 @@ func OrganizationCollectionObjectToSchema(ctx context.Context, obj *models.OrgCo for k, v := range obj.Users { users[k] = map[string]interface{}{ schema_definition.AttributeCollectionMemberHidePasswords: v.HidePasswords, - schema_definition.AttributeCollectionMemberOrgMemberId: v.OrgMemberId, + schema_definition.AttributeID: v.Id, schema_definition.AttributeCollectionMemberReadOnly: v.ReadOnly, - schema_definition.AttributeCollectionMemberUserEmail: v.UserEmail, } } - err = d.Set(schema_definition.AttributeMember, users) + set := schema.NewSet(func(i interface{}) int { + return hashStringToInt(i.(map[string]interface{})[schema_definition.AttributeID].(string)) + }, users) + err = d.Set(schema_definition.AttributeMember, set) if err != nil { return err } @@ -63,17 +66,22 @@ func OrganizationCollectionToObject(ctx context.Context, d *schema.ResourceData) obj.OrganizationID = v } - if v, ok := d.Get(schema_definition.AttributeMember).([]interface{}); ok { - obj.Users = make([]models.OrgCollectionMember, len(v)) - for k, v2 := range v { + if v, ok := d.Get(schema_definition.AttributeMember).(*schema.Set); ok { + obj.Users = make([]models.OrgCollectionMember, v.Len()) + for k, v2 := range v.List() { obj.Users[k] = models.OrgCollectionMember{ HidePasswords: v2.(map[string]interface{})[schema_definition.AttributeCollectionMemberHidePasswords].(bool), + Id: v2.(map[string]interface{})[schema_definition.AttributeID].(string), ReadOnly: v2.(map[string]interface{})[schema_definition.AttributeCollectionMemberReadOnly].(bool), - UserEmail: v2.(map[string]interface{})[schema_definition.AttributeCollectionMemberUserEmail].(string), - - // Note: We don't set OrgMemberId on purpose as it's computed and we're always going to lookup by email. } } } + return obj } + +func hashStringToInt(s string) int { + h := fnv.New32a() + h.Write([]byte(s)) + return int(h.Sum32()) +} diff --git a/internal/transformation/org_member.go b/internal/transformation/org_member.go new file mode 100644 index 0000000..593f782 --- /dev/null +++ b/internal/transformation/org_member.go @@ -0,0 +1,59 @@ +package transformation + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/maxlaverse/terraform-provider-bitwarden/internal/bitwarden/models" + "github.com/maxlaverse/terraform-provider-bitwarden/internal/schema_definition" +) + +func OrganizationMemberObjectToSchema(ctx context.Context, obj *models.OrgMember, d *schema.ResourceData) error { + if obj == nil { + // Object has been deleted + return nil + } + + d.SetId(obj.ID) + + err := d.Set(schema_definition.AttributeObject, models.ObjectTypeOrgMember) + if err != nil { + return err + } + + err = d.Set(schema_definition.AttributeEmail, obj.Email) + if err != nil { + return err + } + + err = d.Set(schema_definition.AttributeName, obj.Name) + if err != nil { + return err + } + + err = d.Set(schema_definition.AttributeOrganizationID, obj.OrganizationId) + if err != nil { + return err + } + + return nil +} + +func OrganizationMemberToObject(ctx context.Context, d *schema.ResourceData) models.OrgMember { + var obj models.OrgMember + + obj.ID = d.Id() + if v, ok := d.Get(schema_definition.AttributeEmail).(string); ok { + obj.Email = v + } + + if v, ok := d.Get(schema_definition.AttributeName).(string); ok { + obj.Name = v + } + + if v, ok := d.Get(schema_definition.AttributeOrganizationID).(string); ok { + obj.OrganizationId = v + } + + return obj +} diff --git a/internal/transformation/transformation_organization.go b/internal/transformation/organization.go similarity index 100% rename from internal/transformation/transformation_organization.go rename to internal/transformation/organization.go