Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support Enterprise-level SSO and EMU #3

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions cmd/emu.go
Original file line number Diff line number Diff line change
@@ -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] <enterprise>",
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
}
50 changes: 50 additions & 0 deletions cmd/ent.go
Original file line number Diff line number Diff line change
@@ -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] <enterprise>",
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
}
98 changes: 98 additions & 0 deletions internal/emu/emu.go
Original file line number Diff line number Diff line change
@@ -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
}
169 changes: 169 additions & 0 deletions internal/ent/ent.go
Original file line number Diff line number Diff line change
@@ -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
}