diff --git a/docs/data-sources/user.md b/docs/data-sources/user.md index 43896833..45d04024 100644 --- a/docs/data-sources/user.md +++ b/docs/data-sources/user.md @@ -21,18 +21,18 @@ data "tailscale_user" "32571345" { ## Schema -### Required +### Optional -- `id` (String) The unique identifier for the user. +- `display_name` (String) The name of the user. +- `login_name` (String) The emailish login name of the user. ### Read-Only - `created` (String) The time the user joined their tailnet. - `currently_connected` (Boolean) true when the user has a node currently connected to the control server. - `device_count` (Number) Number of devices the user owns. -- `display_name` (String) The name of the user. +- `id` (String) The unique identifier for the user. - `last_seen` (String) The later of either: a) The last time any of the user's nodes were connected to the network or b) The last time the user authenticated to any tailscale service, including the admin panel. -- `login_name` (String) The emailish login name of the user. - `profile_pic_url` (String) The profile pic URL for the user. - `role` (String) The role of the user. - `status` (String) The status of the user. diff --git a/tailscale/data_source_user.go b/tailscale/data_source_user.go index 5dca015d..5d713f2c 100644 --- a/tailscale/data_source_user.go +++ b/tailscale/data_source_user.go @@ -2,7 +2,7 @@ package tailscale import ( "context" - "errors" + "fmt" "time" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" @@ -11,20 +11,10 @@ import ( tsclient "github.com/tailscale/tailscale-client-go/v2" ) -var userSchema = map[string]*schema.Schema{ +var commonUserSchema = map[string]*schema.Schema{ "id": { Type: schema.TypeString, Description: "The unique identifier for the user.", - Required: true, - }, - "display_name": { - Type: schema.TypeString, - Description: "The name of the user.", - Computed: true, - }, - "login_name": { - Type: schema.TypeString, - Description: "The emailish login name of the user.", Computed: true, }, "profile_pic_url": { @@ -78,25 +68,70 @@ func dataSourceUser() *schema.Resource { return &schema.Resource{ Description: "The user data source describes a single user in a tailnet", ReadContext: dataSourceUserRead, - Schema: userSchema, + Schema: combinedSchemas(commonUserSchema, map[string]*schema.Schema{ + "login_name": { + Type: schema.TypeString, + Description: "The emailish login name of the user.", + Optional: true, + AtLeastOneOf: []string{"display_name", "login_name"}, + }, + "display_name": { + Type: schema.TypeString, + Description: "The name of the user.", + Optional: true, + AtLeastOneOf: []string{"display_name", "login_name"}, + }, + }), } } func dataSourceUserRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { client := m.(*tsclient.Client) - id, hasID := d.Get("id").(string) - if !hasID { - return diagnosticsError(errors.New("data_source_user missing user ID"), "data_source_user missing user ID") + if id := d.Id(); id != "" { + user, err := client.Users().Get(ctx, id) + if err != nil { + return diagnosticsError(err, "Failed to fetch user with id %s", id) + } + return setProperties(d, userToMap(user)) + } + + var filter func(u tsclient.User) bool + var filterDesc string + + if displayName, ok := d.GetOk("display_name"); ok { + filter = func(u tsclient.User) bool { + return u.DisplayName == displayName.(string) + } + filterDesc = fmt.Sprintf("display_name=%q", displayName.(string)) + } + + if loginName, ok := d.GetOk("login_name"); ok { + filter = func(u tsclient.User) bool { + return u.LoginName == loginName.(string) + } + filterDesc = fmt.Sprintf("login_name=%q", loginName.(string)) } - user, err := client.Users().Get(ctx, id) + users, err := client.Users().List(ctx, nil, nil) if err != nil { - return diagnosticsError(err, "Failed to fetch user with id %s", id) + return diagnosticsError(err, "Failed to fetch users") + } + + var selected *tsclient.User + for _, user := range users { + if filter(user) { + selected = &user + break + } + } + + if selected == nil { + return diag.Errorf("Could not find device with %s", filterDesc) } - d.SetId(user.ID) - return setProperties(d, userToMap(user)) + d.SetId(selected.ID) + return setProperties(d, userToMap(selected)) } // userToMap converts the given user into a map representing the user as a @@ -104,6 +139,7 @@ func dataSourceUserRead(ctx context.Context, d *schema.ResourceData, m interface // using [schema.ResourceData.SetId]. func userToMap(user *tsclient.User) map[string]any { return map[string]any{ + "id": user.ID, "display_name": user.DisplayName, "login_name": user.LoginName, "profile_pic_url": user.ProfilePicURL, diff --git a/tailscale/data_source_users.go b/tailscale/data_source_users.go index 21118ca4..5937d67e 100644 --- a/tailscale/data_source_users.go +++ b/tailscale/data_source_users.go @@ -49,7 +49,18 @@ func dataSourceUsers() *schema.Resource { Type: schema.TypeList, Description: "The list of users in the tailnet", Elem: &schema.Resource{ - Schema: userSchema, + Schema: combinedSchemas(commonUserSchema, map[string]*schema.Schema{ + "login_name": { + Type: schema.TypeString, + Description: "The emailish login name of the user.", + Computed: true, + }, + "display_name": { + Type: schema.TypeString, + Description: "The name of the user.", + Computed: true, + }, + }), }, }, }, @@ -77,7 +88,6 @@ func dataSourceUsersRead(ctx context.Context, d *schema.ResourceData, m interfac userMaps := make([]map[string]interface{}, 0, len(users)) for _, user := range users { m := userToMap(&user) - m["id"] = user.ID userMaps = append(userMaps, m) } diff --git a/tailscale/data_source_users_test.go b/tailscale/data_source_users_test.go index 3660e1f9..b22312ad 100644 --- a/tailscale/data_source_users_test.go +++ b/tailscale/data_source_users_test.go @@ -33,11 +33,10 @@ func TestAccTailscaleUsers(t *testing.T) { return fmt.Errorf("unable to list users: %s", err) } - usersByID := make(map[string]map[string]any) + usersByLoginName := make(map[string]map[string]any) for _, user := range users { m := userToMap(&user) - m["id"] = user.ID - usersByID[user.ID] = m + usersByLoginName[user.LoginName] = m } rs := s.RootModule().Resources[resourceName].Primary @@ -45,15 +44,15 @@ func TestAccTailscaleUsers(t *testing.T) { // first find indexes for users userIndexes := make(map[string]string) for k, v := range rs.Attributes { - if strings.HasSuffix(k, ".id") { + if strings.HasSuffix(k, ".login_name") { idx := strings.Split(k, ".")[1] userIndexes[idx] = v } } // make sure we got the right number of users - if len(userIndexes) != len(usersByID) { - return fmt.Errorf("wrong number of users in datasource, want %d, got %d", len(usersByID), len(userIndexes)) + if len(userIndexes) != len(usersByLoginName) { + return fmt.Errorf("wrong number of users in datasource, want %d, got %d", len(usersByLoginName), len(userIndexes)) } // now compare datasource attributes to expected values @@ -68,18 +67,18 @@ func TestAccTailscaleUsers(t *testing.T) { continue } idx := parts[1] - id := userIndexes[idx] - expected := fmt.Sprint(usersByID[id][prop]) + loginName := userIndexes[idx] + expected := fmt.Sprint(usersByLoginName[loginName][prop]) if v != expected { - return fmt.Errorf("wrong value of %s for user %s, want %q, got %q", prop, id, expected, v) + return fmt.Errorf("wrong value of %s for user %s, want %q, got %q", prop, loginName, expected, v) } } } // Now set up user datasources for each user. This is used in the following test // of the tailscale_user datasource. - for id := range usersByID { - userDataSources.WriteString(fmt.Sprintf("\ndata \"tailscale_user\" \"%s\" {\n id = \"%s\"\n}\n", id, id)) + for loginName, user := range usersByLoginName { + userDataSources.WriteString(fmt.Sprintf("\ndata \"tailscale_user\" \"%s\" {\n login_name = \"%s\"\n}\n", user["id"], loginName)) } return nil diff --git a/tailscale/provider.go b/tailscale/provider.go index 98a98650..1d4fb822 100644 --- a/tailscale/provider.go +++ b/tailscale/provider.go @@ -5,6 +5,7 @@ package tailscale import ( "context" "fmt" + "maps" "net/url" "os" "time" @@ -288,3 +289,12 @@ func optional[T any](d *schema.ResourceData, key string) *T { func isAcceptanceTesting() bool { return os.Getenv("TF_ACC") != "" } + +// combinedSchemas creates a schema that combines two supplied schemas. +// Properties in schema b overwrite the same properties in schema b. +func combinedSchemas(a, b map[string]*schema.Schema) map[string]*schema.Schema { + out := make(map[string]*schema.Schema, len(a)+len(b)) + maps.Copy(out, a) + maps.Copy(out, b) + return out +}