diff --git a/client/client.go b/client/client.go index 6f56717..0f6e2d8 100644 --- a/client/client.go +++ b/client/client.go @@ -3,6 +3,7 @@ package client import ( "context" "fmt" + "regexp" "sort" "strings" @@ -214,3 +215,137 @@ func (oi *OIClient) getUsersInGroupsUnion(groups []string) ([]string, error) { return users, nil } + +type OktaGroup struct { + Name string `json:"name"` + ID string `json:"id"` +} + +func (oi *OIClient) ListGroups() ([]OktaGroup, error) { + var oktaGroups []OktaGroup + + addToGroup := func(g []*okta.Group) { + for _, group := range g { + oktaGroups = append(oktaGroups, OktaGroup{ + Name: group.Profile.Name, + ID: group.Id, + }) + } + } + + qp := query.NewQueryParams() // default limit per docs is 10_000 + + respGroups, resp, err := oi.c.Group.ListGroups(context.TODO(), qp) + if err != nil { + return nil, err + } + addToGroup(respGroups) + + // Pagination + for resp.HasNextPage() { + respGroups = nil + resp, err = resp.Next(context.TODO(), &respGroups) + if err != nil { + return nil, err + } + + addToGroup(respGroups) + } + + return oktaGroups, nil +} + +type OktaGroupRule struct { + Name string `json:"name"` + ID string `json:"id"` + DestinationGroupID string `json:"destination_group_id"` + SourceGroupIDs []string `json:"source_group_ids"` + // Currently we don't support Users assigned via rule, but rather manually to the group + // SourceUserIDs []string `json:"user_ids"` +} + +func (oi *OIClient) ListGroupRules(searchString string) ([]OktaGroupRule, error) { + var oktaGroupRules []OktaGroupRule + + addToGroupRule := func(gr []*okta.GroupRule) error { + for _, groupRule := range gr { + ogr := OktaGroupRule{ + Name: groupRule.Name, + ID: groupRule.Id, + DestinationGroupID: groupRule.Actions.AssignUserToGroups.GroupIds[0], + } + + if groupRule.Actions == nil || groupRule.Actions.AssignUserToGroups == nil || len(groupRule.Actions.AssignUserToGroups.GroupIds) != 1 { + return fmt.Errorf("group rule %s has no destination group", groupRule.Name) + } + ogr.DestinationGroupID = groupRule.Actions.AssignUserToGroups.GroupIds[0] + + if groupRule.Conditions == nil || groupRule.Conditions.Expression == nil { + return fmt.Errorf("group rule %s has no conditions", groupRule.Name) + } + expression := groupRule.Conditions.Expression.Value + ogr.SourceGroupIDs = parseGroupRuleExpression(expression) + + oktaGroupRules = append(oktaGroupRules, ogr) + } + + return nil + } + + opts := []query.ParamOptions{ + query.WithLimit(200), // max limit per docs + } + // use search string if not empty + if searchString != "" { + opts = append(opts, query.WithSearch(searchString)) + } + qp := query.NewQueryParams(opts...) + + groupRules, resp, err := oi.c.Group.ListGroupRules(context.TODO(), qp) + if err != nil { + return nil, err + } + + err = addToGroupRule(groupRules) + if err != nil { + return nil, err + } + + // pagination + for resp.HasNextPage() { + groupRules = nil + resp, err = resp.Next(context.TODO(), &groupRules) + if err != nil { + return nil, err + } + + err = addToGroupRule(groupRules) + if err != nil { + return nil, err + } + } + + return oktaGroupRules, nil +} + +var reGroupRuleExpression = regexp.MustCompile(`"(\w{20})"`) + +// parseGroupRuleExpression parses the expression string from Okta API response +// and returns a slice of group IDs. See TestParseGroupRuleExpression for example input and output. +func parseGroupRuleExpression(expression string) []string { + var groupIDs []string + + matches := reGroupRuleExpression.FindAllStringSubmatch(expression, -1) + + for _, match := range matches { + if len(match) < 2 { + continue + } + + if match[1] != "" { + groupIDs = append(groupIDs, match[1]) + } + } + + return groupIDs +} diff --git a/client/client_test.go b/client/client_test.go new file mode 100644 index 0000000..e38d76e --- /dev/null +++ b/client/client_test.go @@ -0,0 +1,38 @@ +package client + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseGroupRuleExpression(t *testing.T) { + testCases := []struct { + name string + input string + expected []string + }{ + { + name: "3 Groups", + input: `"isMemberOfAnyGroup("00g1lghmvirItveA14x7", "00g360hu5bfvaBHP84x7", "00g1l7ll9aGlqnSg24x7")"`, + expected: []string{ + "00g1lghmvirItveA14x7", + "00g360hu5bfvaBHP84x7", + "00g1l7ll9aGlqnSg24x7", + }, + }, + { + name: "1 Group", + input: `isMemberOfAnyGroup("00gar7xacmKf3wNAt4x7")`, + expected: []string{ + "00gar7xacmKf3wNAt4x7", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, parseGroupRuleExpression(tc.input)) + }) + } +} diff --git a/client/rules.go b/client/rules.go new file mode 100644 index 0000000..db49700 --- /dev/null +++ b/client/rules.go @@ -0,0 +1,132 @@ +package client + +import ( + "fmt" + "slices" + "strings" + "sync" +) + +// PrintGroupRules prints all the group rules that have the searchGroup as either source or destination +func (oi *OIClient) PrintGroupRules(searchGroup string) error { + var wg sync.WaitGroup + + var groups []OktaGroup + + // TODO use errgroup.Group, it should be suited for this kind of use cases + // https://pkg.go.dev/golang.org/x/sync/errgroup + wg.Add(1) + go func() { + var err error + groups, err = oi.ListGroups() + if err != nil { + panic(err) + } + wg.Done() + }() + + var rules []OktaGroupRule + + wg.Add(1) + go func() { + var err error + rules, err = oi.ListGroupRules(searchGroup) + if err != nil { + panic(err) + } + wg.Done() + }() + + wg.Wait() + + // Create a map of groupID -> groupName + groupIDMap := make(map[string]string) + for _, group := range groups { + groupIDMap[group.ID] = group.Name + } + + // replace groupID with groupName in rules + for i, rule := range rules { + rule.DestinationGroupID = groupIDMap[rule.DestinationGroupID] + sourceGroupIDs := make([]string, len(rule.SourceGroupIDs)) + for i, sourceGroupID := range rule.SourceGroupIDs { + sourceGroupIDs[i] = groupIDMap[sourceGroupID] + } + rule.SourceGroupIDs = sourceGroupIDs + + rules[i] = rule + } + + groupRulesString := filterRulesToFormatted(searchGroup, rules) + fmt.Println(groupRulesString) + + return nil +} + +// filterRulesToFormatted filters the rules to only include the ones that have searchGroup as either source or destination +// and formats them in a string that is ready to be printed to terminal +func filterRulesToFormatted(searchGroup string, ogr []OktaGroupRule) string { + var filteredOgr []OktaGroupRule + + sourceMaxLength := 0 + + // Only pick the rules that have searchGroup as either source or destination + for _, o := range ogr { + // is this shouldAdd stuff even needed? + shoulAdd := true + + if strings.EqualFold(o.DestinationGroupID, searchGroup) { + shoulAdd = true + } + + var wantedSourceGroupValue string + for _, sourceGroupID := range o.SourceGroupIDs { + if strings.EqualFold(sourceGroupID, searchGroup) { + sourceMaxLength = max(sourceMaxLength, len(sourceGroupID)) + shoulAdd = true + // we need separate value to make sure Capitalization is proper + wantedSourceGroupValue = sourceGroupID + } + } + + if !shoulAdd { + continue + } + // Only add the dependency to/from wantedValue, ignore other rules + if strings.EqualFold(o.DestinationGroupID, searchGroup) { + for _, sourceGroupID := range o.SourceGroupIDs { + sourceMaxLength = max(sourceMaxLength, len(sourceGroupID)) + } + // add all + filteredOgr = append(filteredOgr, o) + } + // wantedValue is one of the sourceGroups, drop the other sourceGroups + if strings.EqualFold(wantedSourceGroupValue, searchGroup) { + ogrNew := OktaGroupRule{ + DestinationGroupID: o.DestinationGroupID, + SourceGroupIDs: []string{wantedSourceGroupValue}, + } + filteredOgr = append(filteredOgr, ogrNew) + } + } + + // separate slice for printing so we can get output alphabetically sorted + var printSlice []string + + for _, o := range filteredOgr { + for _, sourceGroupID := range o.SourceGroupIDs { + printSlice = append(printSlice, fmt.Sprintf("%-*s -> %s", sourceMaxLength, sourceGroupID, o.DestinationGroupID)) + } + } + + slices.Sort(printSlice) + + var sb strings.Builder + + for _, s := range printSlice { + sb.WriteString(s) + sb.WriteString("\n") + } + + return sb.String() +} diff --git a/go.mod b/go.mod index 69b97a3..3b98506 100644 --- a/go.mod +++ b/go.mod @@ -5,16 +5,19 @@ go 1.21.1 require ( github.com/okta/okta-sdk-golang/v2 v2.20.0 github.com/samber/lo v1.38.1 + github.com/stretchr/testify v1.7.1 ) require ( github.com/BurntSushi/toml v1.1.0 // indirect github.com/cenkalti/backoff/v4 v4.1.3 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/go-jose/go-jose/v3 v3.0.0 // indirect github.com/kelseyhightower/envconfig v1.4.0 // indirect github.com/kr/pretty v0.1.0 // indirect github.com/patrickmn/go-cache v0.0.0-20180815053127-5633e0862627 // indirect - golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/crypto v0.7.0 // indirect golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index d211af5..8a787ec 100644 --- a/go.sum +++ b/go.sum @@ -31,8 +31,8 @@ github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMT github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e h1:T8NU3HyQ8ClP4SEE+KbFlg6n0NhuTsN4MyznaarGsZM= -golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 h1:3MTrJm4PyNL9NBqvYDSj3DHl46qQakyfqfWo4jgfaEM= golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= diff --git a/main.go b/main.go index 74364b3..8bd3fe8 100644 --- a/main.go +++ b/main.go @@ -16,10 +16,19 @@ var ( apiToken = os.Getenv("OKTA_INFO_API_TOKEN") ) +func printHelp() { + fmt.Println("Usage: okta-info ") + fmt.Println("Subcommands:") + fmt.Println(" group - print users in a group") + fmt.Println(" user - print groups for a user") + fmt.Println(" diff - print users in any of groups 1 or 2 but not in groups 3 or 4") + fmt.Println(" rule - print group rules for a group") +} + func run() error { // Check which subcommand was provided if len(os.Args) < 3 { - fmt.Println("Please provide a subcommand and user/group name") + printHelp() os.Exit(1) } @@ -50,8 +59,10 @@ func run() error { hideDeprovisioned := false return oic.PrintGroupDiff(groupsA, groupsB, hideDeprovisioned) + case "rule": + return oic.PrintGroupRules(os.Args[2]) default: - fmt.Println("Invalid subcommand. Valid commands are: group, diff and user") + printHelp() os.Exit(1) } // should not get here ever