From 360b0329c425228acdf2931ccf9a5f688ed6d5c4 Mon Sep 17 00:00:00 2001 From: Robin Breathe Date: Fri, 23 Jun 2023 10:46:12 +0200 Subject: [PATCH] feat: support Enterprise-level SSO and EMU --- cmd/emu.go | 40 +++++++++++ cmd/ent.go | 50 +++++++++++++ internal/emu/emu.go | 98 +++++++++++++++++++++++++ internal/ent/ent.go | 169 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 357 insertions(+) create mode 100644 cmd/emu.go create mode 100644 cmd/ent.go create mode 100644 internal/emu/emu.go create mode 100644 internal/ent/ent.go diff --git a/cmd/emu.go b/cmd/emu.go new file mode 100644 index 0000000..d5dfcdc --- /dev/null +++ b/cmd/emu.go @@ -0,0 +1,40 @@ +package cmd + +import ( + "context" + + "github.com/nexthink-oss/github-enterprise-lookup/internal/auth" + "github.com/nexthink-oss/github-enterprise-lookup/internal/emu" + "github.com/spf13/cobra" +) + +var emuCmd = &cobra.Command{ + Use: "emu [flags] ", + Short: "lookup users in Enterprise with Managed Users", + Args: cobra.ExactArgs(1), + ArgAliases: []string{"enterprise"}, + RunE: runEmuCmd, +} + +func init() { + rootCmd.AddCommand(emuCmd) +} + +func runEmuCmd(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + var err error + client, err = auth.NewTokenClient(ctx) + if err != nil { + return err + } + + enterprise := emu.NewEnterprise(args[0]) + err = enterprise.UpdateMembers(ctx, client) + if err != nil { + return err + } + + members = enterprise.Members + return nil +} diff --git a/cmd/ent.go b/cmd/ent.go new file mode 100644 index 0000000..d37d6bd --- /dev/null +++ b/cmd/ent.go @@ -0,0 +1,50 @@ +package cmd + +import ( + "context" + + "github.com/nexthink-oss/github-enterprise-lookup/internal/auth" + "github.com/nexthink-oss/github-enterprise-lookup/internal/ent" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var entCmd = &cobra.Command{ + Use: "ent [flags] ", + Short: "lookup users in Enterprise with Managed Users", + Args: cobra.ExactArgs(1), + ArgAliases: []string{"enterprise"}, + RunE: runEntCmd, +} + +func init() { + entCmd.PersistentFlags().StringP("verified-email-org", "e", "", "GitHub Organization to use for Verified Email check (defaults to enterprise name)") + viper.BindPFlag("verified_email_org", entCmd.PersistentFlags().Lookup("verified-email-org")) + + rootCmd.AddCommand(entCmd) +} + +func runEntCmd(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + var err error + client, err = auth.NewTokenClient(ctx) + if err != nil { + return err + } + + enterpriseSlug := args[0] + verifiedEmailOrg := viper.GetString("verified_email_org") + if verifiedEmailOrg == "" { + verifiedEmailOrg = enterpriseSlug + } + + enterprise := ent.NewEnterprise(enterpriseSlug, verifiedEmailOrg) + err = enterprise.UpdateMembers(ctx, client) + if err != nil { + return err + } + + members = enterprise.Members + return nil +} diff --git a/internal/emu/emu.go b/internal/emu/emu.go new file mode 100644 index 0000000..f76de9b --- /dev/null +++ b/internal/emu/emu.go @@ -0,0 +1,98 @@ +package emu + +import ( + "context" + + "github.com/shurcooL/githubv4" +) + +type Member struct { + Name string `json:"name" yaml:"name"` + Email string `json:"email" yaml:"email"` +} + +type Enterprise struct { + Name string + Members map[string]Member // key is GitHub login +} + +func NewEnterprise(name string) *Enterprise { + return &Enterprise{ + Name: name, + } +} + +func (ent *Enterprise) UpdateMembers(ctx context.Context, client *githubv4.Client) error { + /* + query($ent: String!, $cursor: String!) { + enterprise(slug: $ent) { + members(first: 100, after: $cursor) { + nodes { + ... on EnterpriseUserAccount { + id + login + name + user { + email + } + } + } + pageInfo { + endCursor + hasNextPage + } + } + } + } + } + {"ent": "nexthink", "cursor": null} + */ + type memberNode struct { + EnterpriseUserAccount struct { + Id githubv4.String + Login githubv4.String + Name githubv4.String + User struct { + Email githubv4.String + } + } `graphql:"... on EnterpriseUserAccount"` + } + var q struct { + Enterprise struct { + Members struct { + Nodes []memberNode + PageInfo struct { + EndCursor githubv4.String + HasNextPage bool + } + } `graphql:"members(first: 100, after: $cursor)"` + } `graphql:"enterprise(slug: $ent)"` + } + variables := map[string]interface{}{ + "ent": (githubv4.String)(ent.Name), + "cursor": (*githubv4.String)(nil), + } + var allMemberNodes []memberNode + for { + err := client.Query(ctx, &q, variables) + if err != nil { + return err + } + allMemberNodes = append(allMemberNodes, q.Enterprise.Members.Nodes...) + if !q.Enterprise.Members.PageInfo.HasNextPage { + break + } + variables["cursor"] = githubv4.NewString(q.Enterprise.Members.PageInfo.EndCursor) + } + + ent.Members = make(map[string]Member) + for _, m := range allMemberNodes { + member := Member{ + Name: string(m.EnterpriseUserAccount.Name), + Email: string(m.EnterpriseUserAccount.User.Email), + } + ent.Members[string(m.EnterpriseUserAccount.Login)] = member + } + + return nil +} diff --git a/internal/ent/ent.go b/internal/ent/ent.go new file mode 100644 index 0000000..2e08f1a --- /dev/null +++ b/internal/ent/ent.go @@ -0,0 +1,169 @@ +package ent + +import ( + "context" + "fmt" + + "github.com/shurcooL/githubv4" +) + +type MemberOrgDetails struct { + SSOName string `json:"sso_name" yaml:"sso_name"` + SSOLogin string `json:"sso_login" yaml:"sso_login"` + SSOEmail string `json:"sso_email" yaml:"sso_email"` + SSOProfileUrl string `json:"sso_profile_url" yaml:"sso_profile_url"` +} + +type Member struct { + GitHubName string `json:"github_name" yaml:"github_name"` + VerifiedEmails []githubv4.String `json:"verified_emails" yaml:"verified_emails,flow"` + Orgs map[string]MemberOrgDetails `json:"orgs" yaml:"orgs"` +} + +type Enterprise struct { + Name string + VerifiedDomainOrg string + Members map[string]Member // map GitHub username to Member object +} + +func NewEnterprise(name string, verifiedDomainOrg string) *Enterprise { + return &Enterprise{ + Name: name, + VerifiedDomainOrg: verifiedDomainOrg, + } +} + +func (ent *Enterprise) UpdateMembers(ctx context.Context, client *githubv4.Client) error { + /* + query { + enterprise(slug: $ent) { + organizations(first: 1, after: $orgCursor) { + nodes { + login + samlIdentityProvider { + externalIdentities(first: 100, after: $userCursor) { + nodes { + scimIdentity { + username + } + user { + login + organizationVerifiedDomainEmails(login: $verifiedDomainOrg) + } + } + pageInfo { + endCursor + hasNextPage + } + } + } + } + pageInfo { + endCursor + hasNextPage + } + } + } + } + {"ent": "enterprise-slug", "verifiedDomainOrg": "org-name" "orgCursor": null, "userCursor": null} + */ + type memberNode struct { + ScimIdentity struct { + Username githubv4.String + GivenName githubv4.String + FamilyName githubv4.String + Emails []struct { + Value githubv4.String + } + } + User struct { + Login githubv4.String // GitHub username + Name githubv4.String // GitHub display name + OrganizationVerifiedDomainEmails []githubv4.String `graphql:"organizationVerifiedDomainEmails(login: $verifiedDomainOrg)"` + } + } + var q struct { + Enterprise struct { + Organizations struct { + Nodes []struct { + Login githubv4.String + SamlIdentityProvider struct { + ExternalIdentities struct { + Nodes []memberNode + PageInfo struct { + EndCursor githubv4.String + HasNextPage bool + } + } `graphql:"externalIdentities(first: 100, after: $userCursor)"` + } + } + PageInfo struct { + EndCursor githubv4.String + HasNextPage bool + } + } `graphql:"organizations(first: 1, after: $orgCursor)"` + } `graphql:"enterprise(slug: $enterprise)"` + } + variables := map[string]interface{}{ + "enterprise": githubv4.String(ent.Name), + "verifiedDomainOrg": githubv4.String(ent.VerifiedDomainOrg), + "orgCursor": (*githubv4.String)(nil), + "userCursor": (*githubv4.String)(nil), + } + + allOrgMembers := make(map[string][]memberNode) + +Query: + for { + err := client.Query(ctx, &q, variables) + if err != nil { + return err + } + + orgNode := q.Enterprise.Organizations.Nodes[0] + orgName := string(orgNode.Login) + + if _, exists := allOrgMembers[orgName]; !exists { + allOrgMembers[orgName] = orgNode.SamlIdentityProvider.ExternalIdentities.Nodes + } else { + allOrgMembers[orgName] = append(allOrgMembers[orgName], orgNode.SamlIdentityProvider.ExternalIdentities.Nodes...) + } + + switch { + case orgNode.SamlIdentityProvider.ExternalIdentities.PageInfo.HasNextPage: + variables["userCursor"] = githubv4.NewString(orgNode.SamlIdentityProvider.ExternalIdentities.PageInfo.EndCursor) + case q.Enterprise.Organizations.PageInfo.HasNextPage: + variables["userCursor"] = (*githubv4.String)(nil) + variables["orgCursor"] = githubv4.NewString(q.Enterprise.Organizations.PageInfo.EndCursor) + default: + break Query + } + } + + ent.Members = make(map[string]Member) + for orgName, orgMemberNodes := range allOrgMembers { + for _, m := range orgMemberNodes { + login := string(m.User.Login) + if login != "" { + member := Member{ + GitHubName: string(m.User.Name), + VerifiedEmails: m.User.OrganizationVerifiedDomainEmails, + } + if _, exists := ent.Members[login]; !exists { + member.Orgs = make(map[string]MemberOrgDetails) + } else { + member.Orgs = ent.Members[login].Orgs + } + member.Orgs[orgName] = MemberOrgDetails{ + SSOName: fmt.Sprintf("%s %s", string(m.ScimIdentity.GivenName), string(m.ScimIdentity.FamilyName)), + SSOLogin: string(m.ScimIdentity.Username), + SSOEmail: string(m.ScimIdentity.Emails[0].Value), // all *members* provisioned by SCIM, so all have at least one email + SSOProfileUrl: fmt.Sprintf("https://github.com/orgs/%s/people/%s/sso", orgName, m.User.Login), + } + ent.Members[login] = member + } + } + } + + return nil +}