diff --git a/docs/index.md b/docs/index.md index ae94a1b..565f0d4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,6 +1,6 @@ # Provider -Terraform provider for 1password usage with your infrastructure, for example you can share password from your admin panel via some vault in you 1password company account. This provider is based on 1Password CLI client version 0.5.5, but you can rewrite it by env variable `OP_VERSION`. +Terraform provider for 1password usage with your infrastructure, for example you can share password from your admin panel via some vault in you 1password company account. This provider is based on 1Password CLI client version 1.4.0, but you can rewrite it by env variable `OP_VERSION`. ## Example Usage diff --git a/docs/resources/group_member.md b/docs/resources/group_member.md new file mode 100644 index 0000000..3e0b409 --- /dev/null +++ b/docs/resources/group_member.md @@ -0,0 +1,43 @@ +# onepassword_group_member + +This resource can manage group membership within a 1Password group. + +## Example Usage + +### Resource + +```hcl +resource "onepassword_group" "group" { + group = "new-group" +} + +data "onepassword_user" "user" { + email = "example@example.com" +} + +resource "onepassword_group_member" "example" { + group = onepassword_group.group.id + user = data.onepassword_user.user.id +} +``` + +## Argument Reference + +* `group` - (Required) group id. +* `user` - (Required) user id. + +## Attribute Reference + +In addition to the above arguments, the following attributes are exported: + +* `id` - (Required) internal membership identifier. + +## Import + +1Password Group Members can be imported using the `id`, which consists of the group ID and user ID separated by a hyphen, e.g. + +``` +terraform import onepassword_group_member.example fmownretj6zdobn2cnjtqqyrae-KDLG56VTIJDXXBXC2KKCPHNHHI +``` + +**Note: this is case sensitive, and matches the case provided by 1Password.** diff --git a/onepassword/group.go b/onepassword/group.go index a0e4da6..dc59bc5 100644 --- a/onepassword/group.go +++ b/onepassword/group.go @@ -2,6 +2,7 @@ package onepassword import ( "encoding/json" + "fmt" ) const ( @@ -35,6 +36,23 @@ func (o *OnePassClient) ReadGroup(id string) (*Group, error) { return group, nil } +// ListGroupMembers lists the existing Users in a given Group +func (o *OnePassClient) ListGroupMembers(id string) ([]User, error) { + users := []User{} + if id == "" { + return users, fmt.Errorf("Must provide an identifier to list group members") + } + + res, err := o.runCmd(opPasswordList, "users", "--"+GroupResource, id) + if err != nil { + return nil, err + } + if err = json.Unmarshal(res, &users); err != nil { + return nil, err + } + return users, nil +} + // CreateGroup creates a new 1Password Group func (o *OnePassClient) CreateGroup(v *Group) (*Group, error) { args := []string{opPasswordCreate, GroupResource, v.Name} @@ -48,6 +66,13 @@ func (o *OnePassClient) CreateGroup(v *Group) (*Group, error) { return v, nil } +// CreateGroupMember adds a User to a Group +func (o *OnePassClient) CreateGroupMember(groupID string, userID string) error { + args := []string{opPasswordAdd, UserResource, userID, groupID} + _, err := o.runCmd(args...) + return err +} + // UpdateGroup updates an existing 1Password Group func (o *OnePassClient) UpdateGroup(id string, v *Group) error { args := []string{opPasswordEdit, GroupResource, id, "--name=" + v.Name} @@ -59,3 +84,10 @@ func (o *OnePassClient) UpdateGroup(id string, v *Group) error { func (o *OnePassClient) DeleteGroup(id string) error { return o.Delete(GroupResource, id) } + +// DeleteGroupMember removes a User from a Group +func (o *OnePassClient) DeleteGroupMember(groupID string, userID string) error { + args := []string{opPasswordRemove, UserResource, userID, groupID} + _, err := o.runCmd(args...) + return err +} diff --git a/onepassword/group_test.go b/onepassword/group_test.go index 17e6abe..5980abd 100644 --- a/onepassword/group_test.go +++ b/onepassword/group_test.go @@ -268,3 +268,181 @@ func TestOnePassClient_DeleteGroup(t *testing.T) { }) } } + +func TestOnePassClient_ListGroupMembers(t *testing.T) { + type fields struct { + runCmd func() (string, error) + } + type args struct { + id string + } + tests := []struct { + name string + fields fields + args args + wantExecResults []string + want []User + wantErr bool + }{ + { + name: "success", + fields: fields{ + runCmd: func() (string, error) { + return `[ { "uuid": "uniq", "firstname": "Testy", "lastname": "Testerton" } ]`, nil + }, + }, + args: args{id: "uniq"}, + wantExecResults: []string{"op", "list", "users", "--group", "uniq", "--session="}, + want: []User{{UUID: "uniq", FirstName: "Testy", LastName: "Testerton"}}, + }, + { + name: "error", + fields: fields{ + runCmd: func() (string, error) { + return ``, fmt.Errorf("oops") + }, + }, + args: args{id: "uniq"}, + wantExecResults: []string{"op", "list", "users", "--group", "uniq", "--session="}, + wantErr: true, + }, + { + name: "error-missing-id", + args: args{id: ""}, + want: []User{}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := &mockOnePassConfig{ + runCmd: tt.fields.runCmd, + } + o := mockOnePassClient(config) + + got, err := o.ListGroupMembers(tt.args.id) + if (err != nil) != tt.wantErr { + t.Errorf("OnePassClient.ListGroupMembers() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("OnePassClient.ListGroupMembers() = %v, want %v", got, tt.want) + } + if !reflect.DeepEqual(config.execCommandResults, tt.wantExecResults) { + t.Errorf("OnePassClient.ListGroupMembers() exec = %v, want %v", config.execCommandResults, tt.wantExecResults) + } + }) + } +} + +func TestOnePassClient_CreateGroupMember(t *testing.T) { + type fields struct { + runCmd func() (string, error) + } + type args struct { + userID string + groupID string + } + tests := []struct { + name string + fields fields + args args + wantExecResults []string + wantErr bool + }{ + { + name: "success", + fields: fields{ + runCmd: func() (string, error) { + return `{ }`, nil + }, + }, + args: args{userID: "userName", groupID: "groupName"}, + wantExecResults: []string{"op", "add", "user", "groupName", "userName", "--session="}, + }, + { + name: "error", + fields: fields{ + runCmd: func() (string, error) { + return ``, fmt.Errorf("oops") + }, + }, + args: args{userID: "userName", groupID: "groupName"}, + wantExecResults: []string{"op", "add", "user", "groupName", "userName", "--session="}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := &mockOnePassConfig{ + runCmd: tt.fields.runCmd, + } + o := mockOnePassClient(config) + + err := o.CreateGroupMember(tt.args.userID, tt.args.groupID) + if (err != nil) != tt.wantErr { + t.Errorf("OnePassClient.ListGroupMembers() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(config.execCommandResults, tt.wantExecResults) { + t.Errorf("OnePassClient.ListGroupMembers() exec = %v, want %v", config.execCommandResults, tt.wantExecResults) + } + }) + } +} + +func TestOnePassClient_DeleteGroupMember(t *testing.T) { + type fields struct { + runCmd func() (string, error) + } + type args struct { + userID string + groupID string + } + tests := []struct { + name string + fields fields + args args + wantExecResults []string + wantErr bool + }{ + { + name: "success", + fields: fields{ + runCmd: func() (string, error) { + return `{ }`, nil + }, + }, + args: args{userID: "userName", groupID: "groupName"}, + wantExecResults: []string{"op", "remove", "user", "groupName", "userName", "--session="}, + }, + { + name: "error", + fields: fields{ + runCmd: func() (string, error) { + return ``, fmt.Errorf("oops") + }, + }, + args: args{userID: "userName", groupID: "groupName"}, + wantExecResults: []string{"op", "remove", "user", "groupName", "userName", "--session="}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := &mockOnePassConfig{ + runCmd: tt.fields.runCmd, + } + o := mockOnePassClient(config) + + err := o.DeleteGroupMember(tt.args.userID, tt.args.groupID) + if (err != nil) != tt.wantErr { + t.Errorf("OnePassClient.ListGroupMembers() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(config.execCommandResults, tt.wantExecResults) { + t.Errorf("OnePassClient.ListGroupMembers() exec = %v, want %v", config.execCommandResults, tt.wantExecResults) + } + }) + } +} diff --git a/onepassword/provider.go b/onepassword/provider.go index 94a29ec..09da68c 100644 --- a/onepassword/provider.go +++ b/onepassword/provider.go @@ -21,7 +21,7 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) -var version string = "0.7.1" +var version string = "1.4.0" func Provider() *schema.Provider { return &schema.Provider{ @@ -58,6 +58,7 @@ func Provider() *schema.Provider { }, ResourcesMap: map[string]*schema.Resource{ "onepassword_group": resourceGroup(), + "onepassword_group_member": resourceGroupMember(), "onepassword_item_common": resourceItemCommon(), "onepassword_item_software_license": resourceItemSoftwareLicense(), "onepassword_item_identity": resourceItemIdentity(), @@ -89,10 +90,15 @@ func providerConfigure(ctx context.Context, d *schema.ResourceData) (interface{} return NewMeta(d) } -const opPasswordCreate = "create" -const opPasswordEdit = "edit" -const opPasswordDelete = "delete" -const opPasswordGet = "get" +const ( + opPasswordAdd = "add" + opPasswordCreate = "create" + opPasswordEdit = "edit" + opPasswordDelete = "delete" + opPasswordGet = "get" + opPasswordList = "list" + opPasswordRemove = "remove" +) type OnePassClient struct { Password string @@ -196,6 +202,10 @@ func installOPClient() (string, error) { } version = semVer.String() } + if runtime.GOOS == "darwin" { + return "", fmt.Errorf("Unable to automatically install v%s of the op client. Please install manually from https://app-updates.agilebits.com/product_history/CLI", version) + } + binZip := fmt.Sprintf("/tmp/op_%s.zip", version) if _, err := os.Stat(binZip); os.IsNotExist(err) { resp, err := http.Get(fmt.Sprintf( @@ -206,20 +216,20 @@ func installOPClient() (string, error) { version, )) if err != nil { - return "", err + return "", fmt.Errorf("Could not retrieve zipped op release: %w", err) } defer resp.Body.Close() out, err := os.Create(binZip) if err != nil { - return "", err + return "", fmt.Errorf("Could not create temp file for op client: %w", err) } defer out.Close() if _, err = io.Copy(out, resp.Body); err != nil { - return "", err + return "", fmt.Errorf("Could not copy zip contents to temp file for op client: %w", err) } if err := unzip(binZip, "/tmp/terraform-provider-onepassword/"+version); err != nil { - return "", err + return "", fmt.Errorf("Could not unzip temp file for op client: %w", err) } } return "/tmp/terraform-provider-onepassword/" + version + "/op", nil diff --git a/onepassword/resource_group_member.go b/onepassword/resource_group_member.go new file mode 100644 index 0000000..d7b20b2 --- /dev/null +++ b/onepassword/resource_group_member.go @@ -0,0 +1,116 @@ +package onepassword + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceGroupMember() *schema.Resource { + return &schema.Resource{ + ReadContext: resourceGroupMemberRead, + CreateContext: resourceGroupMemberCreate, + DeleteContext: resourceGroupMemberDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + Schema: map[string]*schema.Schema{ + "group": { + Type: schema.TypeString, + ForceNew: true, + Required: true, + }, + "user": { + Type: schema.TypeString, + ForceNew: true, + Required: true, + }, + }, + } +} + +func resourceGroupMemberRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + groupID, userID, err := resourceGroupMemberExtractID(d.Id()) + if err != nil { + return diag.FromErr(err) + } + + m := meta.(*Meta) + v, err := m.onePassClient.ListGroupMembers(groupID) + if err != nil { + return diag.FromErr(err) + } + + var found string + for _, member := range v { + if member.UUID == userID { + found = member.UUID + } + } + + if found == "" { + d.SetId("") + return nil + } + + d.SetId(resourceGroupMemberBuildID(groupID, found)) + d.Set("group", groupID) + d.Set("user", found) + return nil +} + +func resourceGroupMemberCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + m := meta.(*Meta) + err := m.onePassClient.CreateGroupMember( + d.Get("group").(string), + d.Get("user").(string), + ) + if err != nil { + return diag.FromErr(err) + } + + d.SetId(resourceGroupMemberBuildID(d.Get("group").(string), d.Get("user").(string))) + return resourceGroupMemberRead(ctx, d, meta) +} + +func resourceGroupMemberDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + groupID, userID, err := resourceGroupMemberExtractID(d.Id()) + if err != nil { + return diag.FromErr(err) + } + + m := meta.(*Meta) + err = m.onePassClient.DeleteGroupMember( + groupID, + userID, + ) + if err != nil { + return diag.FromErr(err) + } + + d.SetId("") + return nil +} + +// resourceGroupMemberBuildID will conjoin the group ID and user ID into a single string +// This is used as the resource ID. +// +// Note that user ID is being lowercased. Some operations require this user ID to be uppercased. +// Use the resourceGroupMemberExtractID function to correctly reverse this encoding. +func resourceGroupMemberBuildID(groupID, userID string) string { + return strings.ToLower(groupID + "-" + strings.ToLower(userID)) +} + +// resourceGroupMemberExtractID will split the group ID and user ID from a given resource ID +// +// Note that user ID is being uppercased. Some operations require this user ID to be uppercased. +func resourceGroupMemberExtractID(id string) (groupID, userID string, err error) { + spl := strings.Split(id, "-") + if len(spl) != 2 { + return "", "", fmt.Errorf("Improperly formatted group member string. The format \"groupid-userid\" is expected") + } + return spl[0], strings.ToUpper(spl[1]), nil +} diff --git a/onepassword/resource_group_member_test.go b/onepassword/resource_group_member_test.go new file mode 100644 index 0000000..4cba315 --- /dev/null +++ b/onepassword/resource_group_member_test.go @@ -0,0 +1,32 @@ +package onepassword + +import "testing" + +func Test_resourceGroupMemberBuildID(t *testing.T) { + want := "v3zk6wiptl42r7cmzbmf23unny-tgkw5a3cpbcu5end3lld3wckxi" + got := resourceGroupMemberBuildID("v3zk6wiptl42r7cmzbmf23unny", "TGKW5A3CPBCU5END3LLD3WCKXI") + + if want != got { + t.Error("Did not correctly conjoin the group and user IDs: " + got) + } +} + +func Test_resourceGroupMemberExtractID(t *testing.T) { + wantGroup := "v3zk6wiptl42r7cmzbmf23unny" + wantUser := "TGKW5A3CPBCU5END3LLD3WCKXI" + gotGroup, gotUser, err := resourceGroupMemberExtractID("v3zk6wiptl42r7cmzbmf23unny-tgkw5a3cpbcu5end3lld3wckxi") + + if err != nil { + t.Error(err) + } else if wantGroup != gotGroup { + t.Error("Did not correctly extract the group ID: " + gotGroup) + } else if wantUser != gotUser { + t.Error("Did not correctly extract the user ID: " + gotUser) + } + + // Test malformed ID + _, _, err = resourceGroupMemberExtractID("totally not the right id") + if err == nil { + t.Error("Error was not returned from malformed id") + } +}