diff --git a/github/data_source_github_enterprise_scim_group.go b/github/data_source_github_enterprise_scim_group.go new file mode 100644 index 0000000000..fc3c5eff37 --- /dev/null +++ b/github/data_source_github_enterprise_scim_group.go @@ -0,0 +1,99 @@ +package github + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func dataSourceGithubEnterpriseSCIMGroup() *schema.Resource { + return &schema.Resource{ + Description: "Lookup SCIM provisioning information for a single GitHub enterprise group.", + ReadContext: dataSourceGithubEnterpriseSCIMGroupRead, + + Schema: map[string]*schema.Schema{ + "enterprise": { + Description: "The enterprise slug.", + Type: schema.TypeString, + Required: true, + }, + "scim_group_id": { + Description: "The SCIM group ID.", + Type: schema.TypeString, + Required: true, + }, + "excluded_attributes": { + Description: "Optional SCIM excludedAttributes query parameter.", + Type: schema.TypeString, + Optional: true, + }, + + "schemas": { + Type: schema.TypeList, + Computed: true, + Description: "SCIM schemas for this group.", + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "id": { + Type: schema.TypeString, + Computed: true, + Description: "The SCIM group ID.", + }, + "external_id": { + Type: schema.TypeString, + Computed: true, + Description: "The external ID for the group.", + }, + "display_name": { + Type: schema.TypeString, + Computed: true, + Description: "The SCIM group displayName.", + }, + "members": { + Type: schema.TypeList, + Computed: true, + Description: "Group members.", + Elem: &schema.Resource{Schema: enterpriseSCIMGroupMemberSchema()}, + }, + "meta": { + Type: schema.TypeList, + Computed: true, + Description: "Resource metadata.", + Elem: &schema.Resource{Schema: enterpriseSCIMMetaSchema()}, + }, + }, + } +} + +func dataSourceGithubEnterpriseSCIMGroupRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + + enterprise := d.Get("enterprise").(string) + scimGroupID := d.Get("scim_group_id").(string) + excluded := d.Get("excluded_attributes").(string) + + path := fmt.Sprintf("scim/v2/enterprises/%s/Groups/%s", enterprise, scimGroupID) + urlStr, err := enterpriseSCIMListURL(path, enterpriseSCIMListOptions{ExcludedAttributes: excluded}) + if err != nil { + return diag.FromErr(err) + } + + group := enterpriseSCIMGroup{} + _, err = enterpriseSCIMGet(ctx, client, urlStr, &group) + if err != nil { + return diag.FromErr(err) + } + + d.SetId(fmt.Sprintf("%s/%s", enterprise, scimGroupID)) + + _ = d.Set("schemas", group.Schemas) + _ = d.Set("id", group.ID) + _ = d.Set("external_id", group.ExternalID) + _ = d.Set("display_name", group.DisplayName) + _ = d.Set("members", flattenEnterpriseSCIMGroupMembers(group.Members)) + _ = d.Set("meta", flattenEnterpriseSCIMMeta(group.Meta)) + + return nil +} diff --git a/github/data_source_github_enterprise_scim_group_test.go b/github/data_source_github_enterprise_scim_group_test.go new file mode 100644 index 0000000000..e6263b3004 --- /dev/null +++ b/github/data_source_github_enterprise_scim_group_test.go @@ -0,0 +1,63 @@ +package github + +import ( + "context" + "net/http" + "net/url" + "testing" + + gh "github.com/google/go-github/v67/github" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func TestDataSourceGithubEnterpriseSCIMGroupRead(t *testing.T) { + ts := githubApiMock([]*mockResponse{ + { + ExpectedUri: "/scim/v2/enterprises/ent/Groups/g1", + ExpectedHeaders: map[string]string{ + "Accept": enterpriseSCIMAcceptHeader, + }, + StatusCode: 200, + ResponseBody: `{ + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], + "id": "g1", + "externalId": "eg1", + "displayName": "Group One", + "members": [{"value": "u1", "$ref": "https://example.test/u1", "display": "user1"}], + "meta": {"resourceType": "Group", "created": "2020-01-01T00:00:00Z"} +}`, + }, + }) + defer ts.Close() + + httpClient := &http.Client{Transport: http.DefaultTransport} + client := gh.NewClient(httpClient) + baseURL, _ := url.Parse(ts.URL + "/") + client.BaseURL = baseURL + + owner := &Owner{v3client: client} + + r := dataSourceGithubEnterpriseSCIMGroup() + d := schema.TestResourceDataRaw(t, r.Schema, map[string]any{ + "enterprise": "ent", + "scim_group_id": "g1", + "excluded_attributes": "", + }) + + diags := dataSourceGithubEnterpriseSCIMGroupRead(context.Background(), d, owner) + if len(diags) > 0 { + t.Fatalf("unexpected diagnostics: %#v", diags) + } + + if got := d.Get("id").(string); got != "g1" { + t.Fatalf("expected id g1, got %q", got) + } + members := d.Get("members").([]any) + if len(members) != 1 { + t.Fatalf("expected 1 member, got %d", len(members)) + } + m0 := members[0].(map[string]any) + if m0["ref"].(string) != "https://example.test/u1" { + t.Fatalf("expected ref to be set, got %v", m0["ref"]) + } +} diff --git a/github/data_source_github_enterprise_scim_groups.go b/github/data_source_github_enterprise_scim_groups.go new file mode 100644 index 0000000000..f8e47fe747 --- /dev/null +++ b/github/data_source_github_enterprise_scim_groups.go @@ -0,0 +1,222 @@ +package github + +import ( + "context" + "fmt" + "net/url" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +func dataSourceGithubEnterpriseSCIMGroups() *schema.Resource { + return &schema.Resource{ + Description: "Lookup SCIM groups provisioned for a GitHub enterprise.", + ReadContext: dataSourceGithubEnterpriseSCIMGroupsRead, + + Schema: map[string]*schema.Schema{ + "enterprise": { + Description: "The enterprise slug.", + Type: schema.TypeString, + Required: true, + }, + "filter": { + Description: "Optional SCIM filter. See GitHub SCIM enterprise docs for supported filters.", + Type: schema.TypeString, + Optional: true, + }, + "excluded_attributes": { + Description: "Optional SCIM excludedAttributes query parameter.", + Type: schema.TypeString, + Optional: true, + }, + "results_per_page": { + Description: "Number of results per request (mapped to SCIM 'count'). Used while auto-fetching all pages.", + Type: schema.TypeInt, + Optional: true, + Default: 100, + ValidateFunc: validation.IntBetween(1, 100), + }, + + "schemas": { + Description: "SCIM response schemas.", + Type: schema.TypeList, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "total_results": { + Description: "The total number of results returned by the SCIM endpoint.", + Type: schema.TypeInt, + Computed: true, + }, + "start_index": { + Description: "The startIndex from the first SCIM page.", + Type: schema.TypeInt, + Computed: true, + }, + "items_per_page": { + Description: "The itemsPerPage from the first SCIM page.", + Type: schema.TypeInt, + Computed: true, + }, + "resources": { + Description: "All SCIM groups.", + Type: schema.TypeList, + Computed: true, + Elem: &schema.Resource{ + Schema: enterpriseSCIMGroupSchema(), + }, + }, + }, + } +} + +func dataSourceGithubEnterpriseSCIMGroupsRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + + enterprise := d.Get("enterprise").(string) + filter := d.Get("filter").(string) + excluded := d.Get("excluded_attributes").(string) + count := d.Get("results_per_page").(int) + + groups, first, err := enterpriseSCIMListAllGroups(ctx, client, enterprise, filter, excluded, count) + if err != nil { + return diag.FromErr(err) + } + + flat := make([]any, 0, len(groups)) + for _, g := range groups { + flat = append(flat, flattenEnterpriseSCIMGroup(g)) + } + + id := fmt.Sprintf("%s/scim-groups", enterprise) + if filter != "" { + id = fmt.Sprintf("%s?filter=%s", id, url.QueryEscape(filter)) + } + if excluded != "" { + if filter == "" { + id = fmt.Sprintf("%s?excluded_attributes=%s", id, url.QueryEscape(excluded)) + } else { + id = fmt.Sprintf("%s&excluded_attributes=%s", id, url.QueryEscape(excluded)) + } + } + + d.SetId(id) + + _ = d.Set("schemas", first.Schemas) + _ = d.Set("total_results", first.TotalResults) + if first.StartIndex > 0 { + _ = d.Set("start_index", first.StartIndex) + } else { + _ = d.Set("start_index", 1) + } + if first.ItemsPerPage > 0 { + _ = d.Set("items_per_page", first.ItemsPerPage) + } else { + _ = d.Set("items_per_page", count) + } + if err := d.Set("resources", flat); err != nil { + return diag.FromErr(fmt.Errorf("error setting resources: %w", err)) + } + + return nil +} + +func enterpriseSCIMMetaSchema() map[string]*schema.Schema { + return map[string]*schema.Schema{ + "resource_type": { + Type: schema.TypeString, + Computed: true, + Description: "The SCIM resource type.", + }, + "created": { + Type: schema.TypeString, + Computed: true, + Description: "The creation timestamp.", + }, + "last_modified": { + Type: schema.TypeString, + Computed: true, + Description: "The lastModified timestamp.", + }, + "location": { + Type: schema.TypeString, + Computed: true, + Description: "The resource location.", + }, + "version": { + Type: schema.TypeString, + Computed: true, + Description: "The resource version.", + }, + "etag": { + Type: schema.TypeString, + Computed: true, + Description: "The resource eTag.", + }, + "password_changed_at": { + Type: schema.TypeString, + Computed: true, + Description: "The password changed at timestamp (if present).", + }, + } +} + +func enterpriseSCIMGroupSchema() map[string]*schema.Schema { + return map[string]*schema.Schema{ + "schemas": { + Type: schema.TypeList, + Computed: true, + Description: "SCIM schemas for this group.", + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "id": { + Type: schema.TypeString, + Computed: true, + Description: "The SCIM group ID.", + }, + "external_id": { + Type: schema.TypeString, + Computed: true, + Description: "The external ID for the group.", + }, + "display_name": { + Type: schema.TypeString, + Computed: true, + Description: "The SCIM group displayName.", + }, + "members": { + Type: schema.TypeList, + Computed: true, + Description: "Group members.", + Elem: &schema.Resource{Schema: enterpriseSCIMGroupMemberSchema()}, + }, + "meta": { + Type: schema.TypeList, + Computed: true, + Description: "Resource metadata.", + Elem: &schema.Resource{Schema: enterpriseSCIMMetaSchema()}, + }, + } +} + +func enterpriseSCIMGroupMemberSchema() map[string]*schema.Schema { + return map[string]*schema.Schema{ + "value": { + Type: schema.TypeString, + Computed: true, + Description: "Member identifier.", + }, + "ref": { + Type: schema.TypeString, + Computed: true, + Description: "Member reference URL.", + }, + "display_name": { + Type: schema.TypeString, + Computed: true, + Description: "Member display name.", + }, + } +} diff --git a/github/data_source_github_enterprise_scim_groups_test.go b/github/data_source_github_enterprise_scim_groups_test.go new file mode 100644 index 0000000000..88da85abe9 --- /dev/null +++ b/github/data_source_github_enterprise_scim_groups_test.go @@ -0,0 +1,83 @@ +package github + +import ( + "context" + "net/http" + "net/url" + "testing" + + gh "github.com/google/go-github/v67/github" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func TestDataSourceGithubEnterpriseSCIMGroupsRead_fetchAllPages(t *testing.T) { + ts := githubApiMock([]*mockResponse{ + { + ExpectedUri: "/scim/v2/enterprises/ent/Groups?count=2&startIndex=1", + ExpectedHeaders: map[string]string{ + "Accept": enterpriseSCIMAcceptHeader, + }, + StatusCode: 200, + ResponseBody: `{ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "totalResults": 3, + "startIndex": 1, + "itemsPerPage": 2, + "Resources": [ + {"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], "id": "g1", "externalId": "eg1", "displayName": "Group One", "meta": {"resourceType": "Group"}}, + {"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], "id": "g2", "externalId": "eg2", "displayName": "Group Two"} + ] +}`, + }, + { + ExpectedUri: "/scim/v2/enterprises/ent/Groups?count=2&startIndex=3", + ExpectedHeaders: map[string]string{ + "Accept": enterpriseSCIMAcceptHeader, + }, + StatusCode: 200, + ResponseBody: `{ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "totalResults": 3, + "startIndex": 3, + "itemsPerPage": 2, + "Resources": [ + {"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], "id": "g3", "externalId": "eg3", "displayName": "Group Three"} + ] +}`, + }, + }) + defer ts.Close() + + httpClient := &http.Client{Transport: http.DefaultTransport} + client := gh.NewClient(httpClient) + baseURL, _ := url.Parse(ts.URL + "/") + client.BaseURL = baseURL + + owner := &Owner{v3client: client} + + r := dataSourceGithubEnterpriseSCIMGroups() + d := schema.TestResourceDataRaw(t, r.Schema, map[string]any{ + "enterprise": "ent", + "results_per_page": 2, + "filter": "", + "excluded_attributes": "", + }) + + diags := dataSourceGithubEnterpriseSCIMGroupsRead(context.Background(), d, owner) + if len(diags) > 0 { + t.Fatalf("unexpected diagnostics: %#v", diags) + } + + if d.Id() == "" { + t.Fatalf("expected ID to be set") + } + + resources := d.Get("resources").([]any) + if len(resources) != 3 { + t.Fatalf("expected 3 groups, got %d", len(resources)) + } + first := resources[0].(map[string]any) + if first["id"].(string) != "g1" { + t.Fatalf("expected first id to be g1, got %v", first["id"]) + } +} diff --git a/github/data_source_github_enterprise_scim_user.go b/github/data_source_github_enterprise_scim_user.go new file mode 100644 index 0000000000..99e094e480 --- /dev/null +++ b/github/data_source_github_enterprise_scim_user.go @@ -0,0 +1,125 @@ +package github + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func dataSourceGithubEnterpriseSCIMUser() *schema.Resource { + return &schema.Resource{ + Description: "Lookup SCIM provisioning information for a single GitHub enterprise user.", + ReadContext: dataSourceGithubEnterpriseSCIMUserRead, + + Schema: map[string]*schema.Schema{ + "enterprise": { + Description: "The enterprise slug.", + Type: schema.TypeString, + Required: true, + }, + "scim_user_id": { + Description: "The SCIM user ID.", + Type: schema.TypeString, + Required: true, + }, + "excluded_attributes": { + Description: "Optional SCIM excludedAttributes query parameter.", + Type: schema.TypeString, + Optional: true, + }, + + "schemas": { + Type: schema.TypeList, + Computed: true, + Description: "SCIM schemas for this user.", + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "id": { + Type: schema.TypeString, + Computed: true, + Description: "The SCIM user ID.", + }, + "external_id": { + Type: schema.TypeString, + Computed: true, + Description: "The external ID for the user.", + }, + "user_name": { + Type: schema.TypeString, + Computed: true, + Description: "The SCIM userName.", + }, + "display_name": { + Type: schema.TypeString, + Computed: true, + Description: "The SCIM displayName.", + }, + "active": { + Type: schema.TypeBool, + Computed: true, + Description: "Whether the user is active.", + }, + "name": { + Type: schema.TypeList, + Computed: true, + Description: "User name object.", + Elem: &schema.Resource{Schema: enterpriseSCIMUserNameSchema()}, + }, + "emails": { + Type: schema.TypeList, + Computed: true, + Description: "User emails.", + Elem: &schema.Resource{Schema: enterpriseSCIMUserEmailSchema()}, + }, + "roles": { + Type: schema.TypeList, + Computed: true, + Description: "User roles.", + Elem: &schema.Resource{Schema: enterpriseSCIMUserRoleSchema()}, + }, + "meta": { + Type: schema.TypeList, + Computed: true, + Description: "Resource metadata.", + Elem: &schema.Resource{Schema: enterpriseSCIMMetaSchema()}, + }, + }, + } +} + +func dataSourceGithubEnterpriseSCIMUserRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + + enterprise := d.Get("enterprise").(string) + scimUserID := d.Get("scim_user_id").(string) + excluded := d.Get("excluded_attributes").(string) + + path := fmt.Sprintf("scim/v2/enterprises/%s/Users/%s", enterprise, scimUserID) + urlStr, err := enterpriseSCIMListURL(path, enterpriseSCIMListOptions{ExcludedAttributes: excluded}) + if err != nil { + return diag.FromErr(err) + } + + user := enterpriseSCIMUser{} + _, err = enterpriseSCIMGet(ctx, client, urlStr, &user) + if err != nil { + return diag.FromErr(err) + } + + d.SetId(fmt.Sprintf("%s/%s", enterprise, scimUserID)) + + _ = d.Set("schemas", user.Schemas) + _ = d.Set("id", user.ID) + _ = d.Set("external_id", user.ExternalID) + _ = d.Set("user_name", user.UserName) + _ = d.Set("display_name", user.DisplayName) + _ = d.Set("active", user.Active) + _ = d.Set("name", flattenEnterpriseSCIMUserName(user.Name)) + _ = d.Set("emails", flattenEnterpriseSCIMUserEmails(user.Emails)) + _ = d.Set("roles", flattenEnterpriseSCIMUserRoles(user.Roles)) + _ = d.Set("meta", flattenEnterpriseSCIMMeta(user.Meta)) + + return nil +} diff --git a/github/data_source_github_enterprise_scim_user_test.go b/github/data_source_github_enterprise_scim_user_test.go new file mode 100644 index 0000000000..acf9700865 --- /dev/null +++ b/github/data_source_github_enterprise_scim_user_test.go @@ -0,0 +1,72 @@ +package github + +import ( + "context" + "net/http" + "net/url" + "testing" + + gh "github.com/google/go-github/v67/github" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func TestDataSourceGithubEnterpriseSCIMUserRead(t *testing.T) { + ts := githubApiMock([]*mockResponse{ + { + ExpectedUri: "/scim/v2/enterprises/ent/Users/u1", + ExpectedHeaders: map[string]string{ + "Accept": enterpriseSCIMAcceptHeader, + }, + StatusCode: 200, + ResponseBody: `{ + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "id": "u1", + "externalId": "eu1", + "userName": "test", + "displayName": "Test User", + "active": true, + "name": {"formatted": "Test User", "familyName": "User", "givenName": "Test"}, + "emails": [{"value": "test@example.com", "type": "work", "primary": true}], + "roles": [{"value": "member", "display": "Member", "type": "direct", "primary": true}], + "meta": {"resourceType": "User"} +}`, + }, + }) + defer ts.Close() + + httpClient := &http.Client{Transport: http.DefaultTransport} + client := gh.NewClient(httpClient) + baseURL, _ := url.Parse(ts.URL + "/") + client.BaseURL = baseURL + + owner := &Owner{v3client: client} + + r := dataSourceGithubEnterpriseSCIMUser() + d := schema.TestResourceDataRaw(t, r.Schema, map[string]any{ + "enterprise": "ent", + "scim_user_id": "u1", + "excluded_attributes": "", + }) + + diags := dataSourceGithubEnterpriseSCIMUserRead(context.Background(), d, owner) + if len(diags) > 0 { + t.Fatalf("unexpected diagnostics: %#v", diags) + } + + if got := d.Get("user_name").(string); got != "test" { + t.Fatalf("expected user_name test, got %q", got) + } + name := d.Get("name").([]any) + if len(name) != 1 { + t.Fatalf("expected name block, got %d", len(name)) + } + n0 := name[0].(map[string]any) + if n0["given_name"].(string) != "Test" { + t.Fatalf("expected given_name Test, got %v", n0["given_name"]) + } + + emails := d.Get("emails").([]any) + if len(emails) != 1 { + t.Fatalf("expected 1 email, got %d", len(emails)) + } +} diff --git a/github/data_source_github_enterprise_scim_users.go b/github/data_source_github_enterprise_scim_users.go new file mode 100644 index 0000000000..4e8433d18f --- /dev/null +++ b/github/data_source_github_enterprise_scim_users.go @@ -0,0 +1,254 @@ +package github + +import ( + "context" + "fmt" + "net/url" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +func dataSourceGithubEnterpriseSCIMUsers() *schema.Resource { + return &schema.Resource{ + Description: "Lookup SCIM users provisioned for a GitHub enterprise.", + ReadContext: dataSourceGithubEnterpriseSCIMUsersRead, + + Schema: map[string]*schema.Schema{ + "enterprise": { + Description: "The enterprise slug.", + Type: schema.TypeString, + Required: true, + }, + "filter": { + Description: "Optional SCIM filter. See GitHub SCIM enterprise docs for supported filters.", + Type: schema.TypeString, + Optional: true, + }, + "excluded_attributes": { + Description: "Optional SCIM excludedAttributes query parameter.", + Type: schema.TypeString, + Optional: true, + }, + "results_per_page": { + Description: "Number of results per request (mapped to SCIM 'count'). Used while auto-fetching all pages.", + Type: schema.TypeInt, + Optional: true, + Default: 100, + ValidateFunc: validation.IntBetween(1, 100), + }, + + "schemas": { + Description: "SCIM response schemas.", + Type: schema.TypeList, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "total_results": { + Description: "The total number of results returned by the SCIM endpoint.", + Type: schema.TypeInt, + Computed: true, + }, + "start_index": { + Description: "The startIndex from the first SCIM page.", + Type: schema.TypeInt, + Computed: true, + }, + "items_per_page": { + Description: "The itemsPerPage from the first SCIM page.", + Type: schema.TypeInt, + Computed: true, + }, + "resources": { + Description: "All SCIM users.", + Type: schema.TypeList, + Computed: true, + Elem: &schema.Resource{ + Schema: enterpriseSCIMUserSchema(), + }, + }, + }, + } +} + +func dataSourceGithubEnterpriseSCIMUsersRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + + enterprise := d.Get("enterprise").(string) + filter := d.Get("filter").(string) + excluded := d.Get("excluded_attributes").(string) + count := d.Get("results_per_page").(int) + + users, first, err := enterpriseSCIMListAllUsers(ctx, client, enterprise, filter, excluded, count) + if err != nil { + return diag.FromErr(err) + } + + flat := make([]any, 0, len(users)) + for _, u := range users { + flat = append(flat, flattenEnterpriseSCIMUser(u)) + } + + id := fmt.Sprintf("%s/scim-users", enterprise) + if filter != "" { + id = fmt.Sprintf("%s?filter=%s", id, url.QueryEscape(filter)) + } + if excluded != "" { + if filter == "" { + id = fmt.Sprintf("%s?excluded_attributes=%s", id, url.QueryEscape(excluded)) + } else { + id = fmt.Sprintf("%s&excluded_attributes=%s", id, url.QueryEscape(excluded)) + } + } + + d.SetId(id) + + _ = d.Set("schemas", first.Schemas) + _ = d.Set("total_results", first.TotalResults) + if first.StartIndex > 0 { + _ = d.Set("start_index", first.StartIndex) + } else { + _ = d.Set("start_index", 1) + } + if first.ItemsPerPage > 0 { + _ = d.Set("items_per_page", first.ItemsPerPage) + } else { + _ = d.Set("items_per_page", count) + } + if err := d.Set("resources", flat); err != nil { + return diag.FromErr(fmt.Errorf("error setting resources: %w", err)) + } + + return nil +} + +func enterpriseSCIMUserSchema() map[string]*schema.Schema { + return map[string]*schema.Schema{ + "schemas": { + Type: schema.TypeList, + Computed: true, + Description: "SCIM schemas for this user.", + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "id": { + Type: schema.TypeString, + Computed: true, + Description: "The SCIM user ID.", + }, + "external_id": { + Type: schema.TypeString, + Computed: true, + Description: "The external ID for the user.", + }, + "user_name": { + Type: schema.TypeString, + Computed: true, + Description: "The SCIM userName.", + }, + "display_name": { + Type: schema.TypeString, + Computed: true, + Description: "The SCIM displayName.", + }, + "active": { + Type: schema.TypeBool, + Computed: true, + Description: "Whether the user is active.", + }, + "name": { + Type: schema.TypeList, + Computed: true, + Description: "User name object.", + Elem: &schema.Resource{Schema: enterpriseSCIMUserNameSchema()}, + }, + "emails": { + Type: schema.TypeList, + Computed: true, + Description: "User emails.", + Elem: &schema.Resource{Schema: enterpriseSCIMUserEmailSchema()}, + }, + "roles": { + Type: schema.TypeList, + Computed: true, + Description: "User roles.", + Elem: &schema.Resource{Schema: enterpriseSCIMUserRoleSchema()}, + }, + "meta": { + Type: schema.TypeList, + Computed: true, + Description: "Resource metadata.", + Elem: &schema.Resource{Schema: enterpriseSCIMMetaSchema()}, + }, + } +} + +func enterpriseSCIMUserNameSchema() map[string]*schema.Schema { + return map[string]*schema.Schema{ + "formatted": { + Type: schema.TypeString, + Computed: true, + Description: "Formatted name.", + }, + "family_name": { + Type: schema.TypeString, + Computed: true, + Description: "Family name.", + }, + "given_name": { + Type: schema.TypeString, + Computed: true, + Description: "Given name.", + }, + "middle_name": { + Type: schema.TypeString, + Computed: true, + Description: "Middle name.", + }, + } +} + +func enterpriseSCIMUserEmailSchema() map[string]*schema.Schema { + return map[string]*schema.Schema{ + "value": { + Type: schema.TypeString, + Computed: true, + Description: "Email address.", + }, + "type": { + Type: schema.TypeString, + Computed: true, + Description: "Email type.", + }, + "primary": { + Type: schema.TypeBool, + Computed: true, + Description: "Whether this email is primary.", + }, + } +} + +func enterpriseSCIMUserRoleSchema() map[string]*schema.Schema { + return map[string]*schema.Schema{ + "value": { + Type: schema.TypeString, + Computed: true, + Description: "Role value.", + }, + "display": { + Type: schema.TypeString, + Computed: true, + Description: "Role display.", + }, + "type": { + Type: schema.TypeString, + Computed: true, + Description: "Role type.", + }, + "primary": { + Type: schema.TypeBool, + Computed: true, + Description: "Whether this role is primary.", + }, + } +} diff --git a/github/data_source_github_enterprise_scim_users_test.go b/github/data_source_github_enterprise_scim_users_test.go new file mode 100644 index 0000000000..7a3d2c29b8 --- /dev/null +++ b/github/data_source_github_enterprise_scim_users_test.go @@ -0,0 +1,80 @@ +package github + +import ( + "context" + "net/http" + "net/url" + "testing" + + gh "github.com/google/go-github/v67/github" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func TestDataSourceGithubEnterpriseSCIMUsersRead_fetchAllPages_withFilter(t *testing.T) { + filter := "userName eq \"test\"" + ts := githubApiMock([]*mockResponse{ + { + ExpectedUri: "/scim/v2/enterprises/ent/Users?count=2&excludedAttributes=members&filter=userName+eq+%22test%22&startIndex=1", + ExpectedHeaders: map[string]string{ + "Accept": enterpriseSCIMAcceptHeader, + }, + StatusCode: 200, + ResponseBody: `{ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "totalResults": 3, + "startIndex": 1, + "itemsPerPage": 2, + "Resources": [ + {"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], "id": "u1", "externalId": "eu1", "userName": "test", "displayName": "Test User", "active": true}, + {"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], "id": "u2", "externalId": "eu2", "userName": "test2", "displayName": "Test User 2", "active": false} + ] +}`, + }, + { + ExpectedUri: "/scim/v2/enterprises/ent/Users?count=2&excludedAttributes=members&filter=userName+eq+%22test%22&startIndex=3", + ExpectedHeaders: map[string]string{ + "Accept": enterpriseSCIMAcceptHeader, + }, + StatusCode: 200, + ResponseBody: `{ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "totalResults": 3, + "startIndex": 3, + "itemsPerPage": 2, + "Resources": [ + {"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], "id": "u3", "externalId": "eu3", "userName": "test3", "displayName": "Test User 3", "active": true} + ] +}`, + }, + }) + defer ts.Close() + + httpClient := &http.Client{Transport: http.DefaultTransport} + client := gh.NewClient(httpClient) + baseURL, _ := url.Parse(ts.URL + "/") + client.BaseURL = baseURL + + owner := &Owner{v3client: client} + + r := dataSourceGithubEnterpriseSCIMUsers() + d := schema.TestResourceDataRaw(t, r.Schema, map[string]any{ + "enterprise": "ent", + "results_per_page": 2, + "filter": filter, + "excluded_attributes": "members", + }) + + diags := dataSourceGithubEnterpriseSCIMUsersRead(context.Background(), d, owner) + if len(diags) > 0 { + t.Fatalf("unexpected diagnostics: %#v", diags) + } + + resources := d.Get("resources").([]any) + if len(resources) != 3 { + t.Fatalf("expected 3 users, got %d", len(resources)) + } + first := resources[0].(map[string]any) + if first["user_name"].(string) != "test" { + t.Fatalf("expected first user_name to be test, got %v", first["user_name"]) + } +} diff --git a/github/provider.go b/github/provider.go index a10d84c027..984aa2212a 100644 --- a/github/provider.go +++ b/github/provider.go @@ -286,6 +286,10 @@ func Provider() *schema.Provider { "github_user_external_identity": dataSourceGithubUserExternalIdentity(), "github_users": dataSourceGithubUsers(), "github_enterprise": dataSourceGithubEnterprise(), + "github_enterprise_scim_groups": dataSourceGithubEnterpriseSCIMGroups(), + "github_enterprise_scim_group": dataSourceGithubEnterpriseSCIMGroup(), + "github_enterprise_scim_users": dataSourceGithubEnterpriseSCIMUsers(), + "github_enterprise_scim_user": dataSourceGithubEnterpriseSCIMUser(), "github_repository_environment_deployment_policies": dataSourceGithubRepositoryEnvironmentDeploymentPolicies(), }, } diff --git a/github/util_enterprise_scim.go b/github/util_enterprise_scim.go new file mode 100644 index 0000000000..7e6da36330 --- /dev/null +++ b/github/util_enterprise_scim.go @@ -0,0 +1,312 @@ +package github + +import ( + "context" + "fmt" + "net/url" + "strconv" + + gh "github.com/google/go-github/v67/github" +) + +const enterpriseSCIMAcceptHeader = "application/scim+json" + +type enterpriseSCIMListOptions struct { + Filter string + ExcludedAttributes string + StartIndex int + Count int +} + +type enterpriseSCIMListResponse[T any] struct { + Schemas []string `json:"schemas,omitempty"` + TotalResults int `json:"totalResults,omitempty"` + StartIndex int `json:"startIndex,omitempty"` + ItemsPerPage int `json:"itemsPerPage,omitempty"` + Resources []T `json:"Resources,omitempty"` +} + +type enterpriseSCIMMeta struct { + ResourceType string `json:"resourceType,omitempty"` + Created string `json:"created,omitempty"` + LastModified string `json:"lastModified,omitempty"` + Location string `json:"location,omitempty"` + Version string `json:"version,omitempty"` + ETag string `json:"eTag,omitempty"` + PasswordChgAt string `json:"passwordChangedAt,omitempty"` +} + +type enterpriseSCIMGroupMember struct { + Value string `json:"value,omitempty"` + Ref string `json:"$ref,omitempty"` + Display string `json:"display,omitempty"` +} + +type enterpriseSCIMGroup struct { + Schemas []string `json:"schemas,omitempty"` + ID string `json:"id,omitempty"` + ExternalID string `json:"externalId,omitempty"` + DisplayName string `json:"displayName,omitempty"` + Members []enterpriseSCIMGroupMember `json:"members,omitempty"` + Meta *enterpriseSCIMMeta `json:"meta,omitempty"` +} + +type enterpriseSCIMUserName struct { + Formatted string `json:"formatted,omitempty"` + FamilyName string `json:"familyName,omitempty"` + GivenName string `json:"givenName,omitempty"` + MiddleName string `json:"middleName,omitempty"` +} + +type enterpriseSCIMUserEmail struct { + Value string `json:"value,omitempty"` + Type string `json:"type,omitempty"` + Primary bool `json:"primary,omitempty"` +} + +type enterpriseSCIMUserRole struct { + Value string `json:"value,omitempty"` + Display string `json:"display,omitempty"` + Type string `json:"type,omitempty"` + Primary bool `json:"primary,omitempty"` +} + +type enterpriseSCIMUser struct { + Schemas []string `json:"schemas,omitempty"` + ID string `json:"id,omitempty"` + ExternalID string `json:"externalId,omitempty"` + UserName string `json:"userName,omitempty"` + DisplayName string `json:"displayName,omitempty"` + Active bool `json:"active,omitempty"` + Name *enterpriseSCIMUserName `json:"name,omitempty"` + Emails []enterpriseSCIMUserEmail `json:"emails,omitempty"` + Roles []enterpriseSCIMUserRole `json:"roles,omitempty"` + Meta *enterpriseSCIMMeta `json:"meta,omitempty"` +} + +func enterpriseSCIMListURL(path string, opts enterpriseSCIMListOptions) (string, error) { + u, err := url.Parse(path) + if err != nil { + return "", err + } + q := u.Query() + if opts.Filter != "" { + q.Set("filter", opts.Filter) + } + if opts.ExcludedAttributes != "" { + q.Set("excludedAttributes", opts.ExcludedAttributes) + } + if opts.StartIndex > 0 { + q.Set("startIndex", strconv.Itoa(opts.StartIndex)) + } + if opts.Count > 0 { + q.Set("count", strconv.Itoa(opts.Count)) + } + u.RawQuery = q.Encode() + return u.String(), nil +} + +func enterpriseSCIMGet[T any](ctx context.Context, client *gh.Client, urlStr string, out *T) (*gh.Response, error) { + req, err := client.NewRequest("GET", urlStr, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", enterpriseSCIMAcceptHeader) + return client.Do(ctx, req, out) +} + +func enterpriseSCIMListAllGroups(ctx context.Context, client *gh.Client, enterprise, filter, excludedAttributes string, count int) ([]enterpriseSCIMGroup, *enterpriseSCIMListResponse[enterpriseSCIMGroup], error) { + startIndex := 1 + all := make([]enterpriseSCIMGroup, 0) + var firstResp *enterpriseSCIMListResponse[enterpriseSCIMGroup] + + for { + path := fmt.Sprintf("scim/v2/enterprises/%s/Groups", enterprise) + urlStr, err := enterpriseSCIMListURL(path, enterpriseSCIMListOptions{ + Filter: filter, + ExcludedAttributes: excludedAttributes, + StartIndex: startIndex, + Count: count, + }) + if err != nil { + return nil, nil, err + } + + page := enterpriseSCIMListResponse[enterpriseSCIMGroup]{} + _, err = enterpriseSCIMGet(ctx, client, urlStr, &page) + if err != nil { + return nil, nil, err + } + + if firstResp == nil { + snap := page + firstResp = &snap + } + + all = append(all, page.Resources...) + + if len(page.Resources) == 0 { + break + } + if page.TotalResults > 0 && len(all) >= page.TotalResults { + break + } + + startIndex += len(page.Resources) + } + + if firstResp == nil { + firstResp = &enterpriseSCIMListResponse[enterpriseSCIMGroup]{ + Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:ListResponse"}, + TotalResults: len(all), + StartIndex: 1, + ItemsPerPage: count, + Resources: nil, + } + } + + return all, firstResp, nil +} + +func enterpriseSCIMListAllUsers(ctx context.Context, client *gh.Client, enterprise, filter, excludedAttributes string, count int) ([]enterpriseSCIMUser, *enterpriseSCIMListResponse[enterpriseSCIMUser], error) { + startIndex := 1 + all := make([]enterpriseSCIMUser, 0) + var firstResp *enterpriseSCIMListResponse[enterpriseSCIMUser] + + for { + path := fmt.Sprintf("scim/v2/enterprises/%s/Users", enterprise) + urlStr, err := enterpriseSCIMListURL(path, enterpriseSCIMListOptions{ + Filter: filter, + ExcludedAttributes: excludedAttributes, + StartIndex: startIndex, + Count: count, + }) + if err != nil { + return nil, nil, err + } + + page := enterpriseSCIMListResponse[enterpriseSCIMUser]{} + _, err = enterpriseSCIMGet(ctx, client, urlStr, &page) + if err != nil { + return nil, nil, err + } + + if firstResp == nil { + snap := page + firstResp = &snap + } + + all = append(all, page.Resources...) + + if len(page.Resources) == 0 { + break + } + if page.TotalResults > 0 && len(all) >= page.TotalResults { + break + } + + startIndex += len(page.Resources) + } + + if firstResp == nil { + firstResp = &enterpriseSCIMListResponse[enterpriseSCIMUser]{ + Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:ListResponse"}, + TotalResults: len(all), + StartIndex: 1, + ItemsPerPage: count, + Resources: nil, + } + } + + return all, firstResp, nil +} + +func flattenEnterpriseSCIMMeta(meta *enterpriseSCIMMeta) []any { + if meta == nil { + return nil + } + return []any{map[string]any{ + "resource_type": meta.ResourceType, + "created": meta.Created, + "last_modified": meta.LastModified, + "location": meta.Location, + "version": meta.Version, + "etag": meta.ETag, + "password_changed_at": meta.PasswordChgAt, + }} +} + +func flattenEnterpriseSCIMGroupMembers(members []enterpriseSCIMGroupMember) []any { + out := make([]any, 0, len(members)) + for _, m := range members { + out = append(out, map[string]any{ + "value": m.Value, + "ref": m.Ref, + "display_name": m.Display, + }) + } + return out +} + +func flattenEnterpriseSCIMGroup(group enterpriseSCIMGroup) map[string]any { + return map[string]any{ + "schemas": group.Schemas, + "id": group.ID, + "external_id": group.ExternalID, + "display_name": group.DisplayName, + "members": flattenEnterpriseSCIMGroupMembers(group.Members), + "meta": flattenEnterpriseSCIMMeta(group.Meta), + } +} + +func flattenEnterpriseSCIMUserName(name *enterpriseSCIMUserName) []any { + if name == nil { + return nil + } + return []any{map[string]any{ + "formatted": name.Formatted, + "family_name": name.FamilyName, + "given_name": name.GivenName, + "middle_name": name.MiddleName, + }} +} + +func flattenEnterpriseSCIMUserEmails(emails []enterpriseSCIMUserEmail) []any { + out := make([]any, 0, len(emails)) + for _, e := range emails { + out = append(out, map[string]any{ + "value": e.Value, + "type": e.Type, + "primary": e.Primary, + }) + } + return out +} + +func flattenEnterpriseSCIMUserRoles(roles []enterpriseSCIMUserRole) []any { + out := make([]any, 0, len(roles)) + for _, r := range roles { + out = append(out, map[string]any{ + "value": r.Value, + "display": r.Display, + "type": r.Type, + "primary": r.Primary, + }) + } + return out +} + +func flattenEnterpriseSCIMUser(user enterpriseSCIMUser) map[string]any { + return map[string]any{ + "schemas": user.Schemas, + "id": user.ID, + "external_id": user.ExternalID, + "user_name": user.UserName, + "display_name": user.DisplayName, + "active": user.Active, + "name": flattenEnterpriseSCIMUserName(user.Name), + "emails": flattenEnterpriseSCIMUserEmails(user.Emails), + "roles": flattenEnterpriseSCIMUserRoles(user.Roles), + "meta": flattenEnterpriseSCIMMeta(user.Meta), + } +} diff --git a/website/docs/d/enterprise_scim_group.html.markdown b/website/docs/d/enterprise_scim_group.html.markdown new file mode 100644 index 0000000000..f1b0691024 --- /dev/null +++ b/website/docs/d/enterprise_scim_group.html.markdown @@ -0,0 +1,29 @@ +--- +layout: "github" +page_title: "Github: github_enterprise_scim_group" +description: |- + Get SCIM provisioning information for a GitHub enterprise group. +--- + +# github_enterprise_scim_group + +Use this data source to retrieve SCIM provisioning information for a single enterprise group. + +## Example Usage + +``` +data "github_enterprise_scim_group" "example" { + enterprise = "example-co" + scim_group_id = "123456" +} +``` + +## Argument Reference + +* `enterprise` - (Required) The enterprise slug. +* `scim_group_id` - (Required) The SCIM group ID. +* `excluded_attributes` - (Optional) SCIM `excludedAttributes` query parameter. + +## Attributes Reference + +* `schemas`, `id`, `external_id`, `display_name`, `members`, `meta`. diff --git a/website/docs/d/enterprise_scim_groups.html.markdown b/website/docs/d/enterprise_scim_groups.html.markdown new file mode 100644 index 0000000000..6b270f8183 --- /dev/null +++ b/website/docs/d/enterprise_scim_groups.html.markdown @@ -0,0 +1,50 @@ +--- +layout: "github" +page_title: "Github: github_enterprise_scim_groups" +description: |- + Get SCIM groups provisioned for a GitHub enterprise. +--- + +# github_enterprise_scim_groups + +Use this data source to retrieve SCIM groups provisioned for a GitHub enterprise. + +## Example Usage + +``` +data "github_enterprise_scim_groups" "example" { + enterprise = "example-co" +} +``` + +## Argument Reference + +* `enterprise` - (Required) The enterprise slug. +* `filter` - (Optional) SCIM filter string. +* `excluded_attributes` - (Optional) SCIM `excludedAttributes` query parameter. +* `results_per_page` - (Optional) Page size used while auto-fetching all pages (mapped to SCIM `count`). + +### Notes on `filter` + +`filter` is passed to the GitHub SCIM API as-is (server-side filtering). It is **not** a Terraform expression and it does **not** understand provider schema paths. + +GitHub supports **only one** filter expression and only for these attributes on the enterprise `Groups` listing endpoint: + +* `externalId` +* `id` +* `displayName` + +Example: + +``` +filter = "displayName eq \"Engineering\"" +``` + +## Attributes Reference + +* `schemas` - SCIM response schemas. +* `total_results` - Total number of SCIM groups. +* `start_index` - Start index from the first page. +* `items_per_page` - Items per page from the first page. +* `resources` - List of SCIM groups. Each entry includes: + * `schemas`, `id`, `external_id`, `display_name`, `members`, `meta`. diff --git a/website/docs/d/enterprise_scim_user.html.markdown b/website/docs/d/enterprise_scim_user.html.markdown new file mode 100644 index 0000000000..94695cfcf2 --- /dev/null +++ b/website/docs/d/enterprise_scim_user.html.markdown @@ -0,0 +1,29 @@ +--- +layout: "github" +page_title: "Github: github_enterprise_scim_user" +description: |- + Get SCIM provisioning information for a GitHub enterprise user. +--- + +# github_enterprise_scim_user + +Use this data source to retrieve SCIM provisioning information for a single enterprise user. + +## Example Usage + +``` +data "github_enterprise_scim_user" "example" { + enterprise = "example-co" + scim_user_id = "123456" +} +``` + +## Argument Reference + +* `enterprise` - (Required) The enterprise slug. +* `scim_user_id` - (Required) The SCIM user ID. +* `excluded_attributes` - (Optional) SCIM `excludedAttributes` query parameter. + +## Attributes Reference + +* `schemas`, `id`, `external_id`, `user_name`, `display_name`, `active`, `name`, `emails`, `roles`, `meta`. diff --git a/website/docs/d/enterprise_scim_users.html.markdown b/website/docs/d/enterprise_scim_users.html.markdown new file mode 100644 index 0000000000..796c6c4279 --- /dev/null +++ b/website/docs/d/enterprise_scim_users.html.markdown @@ -0,0 +1,57 @@ +--- +layout: "github" +page_title: "Github: github_enterprise_scim_users" +description: |- + Get SCIM users provisioned for a GitHub enterprise. +--- + +# github_enterprise_scim_users + +Use this data source to retrieve SCIM users provisioned for a GitHub enterprise. + +## Example Usage + +``` +data "github_enterprise_scim_users" "example" { + enterprise = "example-co" +} +``` + +## Argument Reference + +* `enterprise` - (Required) The enterprise slug. +* `filter` - (Optional) SCIM filter string. +* `excluded_attributes` - (Optional) SCIM `excludedAttributes` query parameter. +* `results_per_page` - (Optional) Page size used while auto-fetching all pages (mapped to SCIM `count`). + +### Notes on `filter` + +`filter` is passed to the GitHub SCIM API as-is (server-side filtering). It is **not** a Terraform expression and it does **not** understand provider schema paths such as `name[0].family_name`. + +GitHub supports **only one** filter expression and only for these attributes on the enterprise `Users` listing endpoint: + +* `userName` +* `externalId` +* `id` +* `displayName` + +Examples: + +``` +filter = "userName eq \"E012345\"" +``` + +``` +filter = "externalId eq \"9138790-10932-109120392-12321\"" +``` + +If you need to filter by other values that only exist in the Terraform schema (for example `name[0].family_name`), retrieve the users and filter locally in Terraform. + +## Attributes Reference + +* `schemas` - SCIM response schemas. +* `total_results` - Total number of SCIM users. +* `start_index` - Start index from the first page. +* `items_per_page` - Items per page from the first page. +* `resources` - List of SCIM users. Each entry includes: + * `schemas`, `id`, `external_id`, `user_name`, `display_name`, `active`, `name`, `emails`, `roles`, `meta`.