From 18af1b6073f58fc345ea07b9f18332a02ce3ea71 Mon Sep 17 00:00:00 2001 From: "T. von Dein" Date: Wed, 3 Jun 2026 08:47:54 +0200 Subject: [PATCH] add role diff (#26) --- cmd/roles.go | 59 ++++++++ pkg/cfg/config.go | 6 +- pkg/es/errors.go | 7 +- pkg/es/role.go | 6 +- pkg/es/role_diff.go | 354 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 425 insertions(+), 7 deletions(-) create mode 100644 pkg/es/role_diff.go diff --git a/cmd/roles.go b/cmd/roles.go index 89538a9..d932c28 100644 --- a/cmd/roles.go +++ b/cmd/roles.go @@ -34,6 +34,7 @@ func Roles(conf *cfg.Config) *cli.Command { Commands: []*cli.Command{ RoleList(conf), RoleShow(conf), + RoleDiff(conf), }, } } @@ -76,3 +77,61 @@ func RoleShow(conf *cfg.Config) *cli.Command { }, } } + +func RoleDiff(conf *cfg.Config) *cli.Command { + return &cli.Command{ + Name: "diff", + Usage: "show differences between roles and CSV baseline", + UsageText: `diff [options] [] +CSV format: +index_name;role;index_privilege;cluster_privilege;ad_group;space;retention;kibana_privilege;field_privilege`, + + MutuallyExclusiveFlags: []cli.MutuallyExclusiveFlags{{ + Flags: [][]cli.Flag{ + { + &cli.BoolFlag{ + Name: "not-deployed", + Usage: "include only not deployed but defined roles", + Destination: &conf.NotDeployed, + Aliases: []string{"n"}, + }, + }, + { + &cli.BoolFlag{ + Name: "undefined", + Usage: "include only deployed but undefined roles", + Destination: &conf.Undefined, + Aliases: []string{"u"}, + }, + }, + { + &cli.BoolFlag{ + Name: "diff", + Usage: "include only differing roles", + Destination: &conf.Diff, + Aliases: []string{"D"}, + }, + }, + }}, + }, + + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "separator", + Usage: "CSV field separator", + Destination: &conf.Separator, + Aliases: []string{"s"}, + Value: ",", + }, + }, + + Action: func(ctx context.Context, cmd *cli.Command) error { + csvfile := cmd.Args().Get(0) + if csvfile == "" { + return errors.New("no CSV file specified") + } + + return es.RoleDiff(conf, cmd.Args().Get(0), cmd.Args().Get(1)) + }, + } +} diff --git a/pkg/cfg/config.go b/pkg/cfg/config.go index 80800fb..31dd365 100644 --- a/pkg/cfg/config.go +++ b/pkg/cfg/config.go @@ -34,7 +34,7 @@ import ( ) const ( - Version string = `v0.0.15` + Version string = `v0.0.16` ) var ( @@ -76,6 +76,10 @@ type Config struct { Force bool // ccr follower renew: -f HaveJQ bool // determined at runtime by ourselfes DebugHTTP bool // root: --debug-http + Separator string // role diff: -s + NotDeployed bool // role diff: -n + Undefined bool // role diff: -u + Diff bool // role diff: -D } func NewConfig() *Config { diff --git a/pkg/es/errors.go b/pkg/es/errors.go index 6da6ae9..7cda3b2 100644 --- a/pkg/es/errors.go +++ b/pkg/es/errors.go @@ -33,7 +33,12 @@ func esErrorString(err error) string { causes += fmt.Sprintf("%s\n", *cause.Reason) } - msg = *e.ErrorCause.Reason + ": " + causes + if e.ErrorCause.Reason != nil { + msg = *e.ErrorCause.Reason + ": " + causes + } else { + msg = fmt.Sprintf("http status %d: ", e.Status) + } + } return msg diff --git a/pkg/es/role.go b/pkg/es/role.go index 6284600..8a922f8 100644 --- a/pkg/es/role.go +++ b/pkg/es/role.go @@ -50,11 +50,7 @@ func RoleList(conf *cfg.Config) error { } table.Sort() - if err := table.Print(); err != nil { - return err - } - - return nil + return table.Print() } func RoleShow(conf *cfg.Config, rolename string) error { diff --git a/pkg/es/role_diff.go b/pkg/es/role_diff.go new file mode 100644 index 0000000..9bcd99c --- /dev/null +++ b/pkg/es/role_diff.go @@ -0,0 +1,354 @@ +/* +Copyright © 2026 Thomas von Dein + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ +package es + +import ( + "bufio" + "bytes" + "context" + "encoding/csv" + "fmt" + "log" + "log/slog" + "os" + "slices" + "strings" + + "codeberg.org/scip/esctl/pkg/cfg" + "codeberg.org/scip/esctl/pkg/printer" + "github.com/alecthomas/repr" + "github.com/elastic/go-elasticsearch/v9/typedapi/security/getrole" +) + +// use static csv record positions as const vars so we can modify it +// if the csv format ever changes +const ( + Rindexname = iota + Rrole + Rindexprivilege + Rclusterprivilege + Radgroup + Rspace + Rretention + Rkibanaprivilege + Rfieldprivilege +) + +type Record struct { + // filled from CSV input + index_name string + role string + index_privilege string + cluster_privilege []string + ad_group []string + space string + retention string + kibana_privilege string + field_privilege string + + // set by ourselfes + defined bool +} + +type Register struct { + name string + deployed, defined bool +} + +// generic variant, we do not account for multiple rows of the same +// role, in such cases an entry will simply overwritten. Use +// getCsvRecord() for a single role. +func getCsvRecords(conf *cfg.Config, csvfile string) (map[string]Record, error) { + data, err := os.ReadFile(csvfile) + if err != nil { + return nil, fmt.Errorf("failed to read CSV file: %s", err) + } + + csvreader := csv.NewReader(bytes.NewReader(data)) + csvreader.Comma = rune(conf.Separator[0]) + csvreader.Comment = '#' + csvreader.TrimLeadingSpace = true + + rows, err := csvreader.ReadAll() + if err != nil { + return nil, fmt.Errorf("failed to parse CSV: %s", err) + } + + records := make(map[string]Record, len(rows)-1) + + for idx, row := range rows { + if idx == 0 { + continue // header + } + + records[row[1]] = Record{ + index_name: row[Rindexname], + role: row[Rrole], + index_privilege: row[Rindexprivilege], + cluster_privilege: []string{row[Rindexprivilege]}, + ad_group: []string{row[Rclusterprivilege]}, + space: row[Rspace], + retention: row[Rretention], + kibana_privilege: row[Rkibanaprivilege], + field_privilege: row[Rfieldprivilege], + defined: true, + } + } + + return records, nil +} + +// same thing as above but for one specific role. supports multiple +// rows of the same record with different values which will be +// combined. +func getCsvRecord(conf *cfg.Config, csvfile, rolename string) (*Record, error) { + fd, err := os.Open(csvfile) + if err != nil { + return nil, fmt.Errorf("failed to open CSV file: %s", err) + } + defer func() { + if err := fd.Close(); err != nil { + log.Fatalf("failed to close file: %s", err) + } + }() + + scanner := bufio.NewScanner(fd) + record := Record{role: rolename} + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "#") || line == "" { + continue + } + + row := strings.Split(line, conf.Separator) + + if row[Rrole] == rolename { + record.index_name = row[Rindexname] + record.index_privilege = row[Rindexprivilege] + record.cluster_privilege = strings.Split(row[Rclusterprivilege], ",") + record.ad_group = append(record.ad_group, row[Radgroup]) + record.space = row[Rspace] + record.retention = row[Rretention] + record.kibana_privilege = row[Rkibanaprivilege] + record.field_privilege = row[Rfieldprivilege] + record.defined = true + } + } + + return &record, nil +} + +func diffRoles(conf *cfg.Config, records map[string]Record, res getrole.Response) []Register { + rows := []Register{} + filtered := []Register{} + deployed := map[string]int{} + + // iterate over deployed roles + for name := range res { + reg := Register{name: name} + + _, defined := records[name] + if defined { + reg.deployed = true + reg.defined = true + } else { + reg.deployed = true + reg.defined = false + } + + deployed[name] = 1 + + rows = append(rows, reg) + } + + // iterate over records from CSV and register only those which are not deployed + for name := range records { + reg := Register{name: name, defined: true} + _, deployed := deployed[name] + if !deployed { + rows = append(rows, reg) + } + } + + for _, reg := range rows { + if (conf.NotDeployed && !reg.deployed) || + (conf.Undefined && !reg.defined) || + (conf.Diff && reg.deployed != reg.defined) || + (!conf.Undefined && !conf.NotDeployed && !conf.Diff) { + filtered = append(filtered, reg) + } + } + + return filtered +} + +func RoleDiff(conf *cfg.Config, csvfile, role string) error { + records, err := getCsvRecords(conf, csvfile) + if err != nil { + return err + } + + if role != "" { + return RoleDiffSingle(conf, csvfile, role) + } + + res, err := conf.DefaultCluster.ES.Security.GetRole(). + Do(context.Background()) + if err != nil { + return fmt.Errorf("failed to get roles: %s", esErrorString(err)) + } + + rows := diffRoles(conf, records, res) + + table := printer.NewTable(conf, 3, len(rows)) + table.Addheaders("role", "is deployed", "is defined") + + for idx, row := range rows { + deployed := printer.Colorize(conf, "green", "deployed") + if !row.deployed { + deployed = printer.Colorize(conf, "red", "not deployed") + } + + defined := printer.Colorize(conf, "green", "defined") + if !row.defined { + defined = printer.Colorize(conf, "red", "undefined") + } + + table.Entries[idx] = []string{ + row.name, + deployed, + defined, + } + } + + table.Sort() + + return table.Print() +} + +func getRoleMappingGroups(conf *cfg.Config, rolename string) ([]string, error) { + mappings, err := conf.DefaultCluster.ES.Security.GetRoleMapping(). + Do(context.Background()) + if err != nil { + return nil, fmt.Errorf("failed to get role mappings: %s", esErrorString(err)) + } + + groups := []string{} + for _, mapping := range mappings { + if slices.Contains(mapping.Roles, rolename) { + for _, rule := range mapping.Rules.Any { + for _, group := range rule.Field["groups"] { + groups = append(groups, group.(string)) + } + } + } + } + + return groups, nil +} + +func compareSlices(name string, a, b []string) { + slices.Sort(a) + slices.Sort(b) + + if slices.Compare(a, b) != 0 { + fmt.Printf("%s differs:\ndeployed: %s\n csv: %s\n", + name, strings.Join(a, ","), strings.Join(b, ",")) + } else { + fmt.Printf("deployed %s matches csv definition\n", name) + } +} + +func RoleDiffSingle(conf *cfg.Config, csvfile, rolename string) error { + res, err := conf.DefaultCluster.ES.Security.GetRole(). + Name(rolename). + Do(context.Background()) + if err != nil { + return fmt.Errorf("failed to get role: %s", esErrorString(err)) + } + + record, err := getCsvRecord(conf, csvfile, rolename) + if err != nil { + return err + } + + if !record.defined { + fmt.Printf("role %s is not defined\n", rolename) + return nil + } + + role, exists := res[rolename] + if !exists { + fmt.Printf("role %s is not deployed\n", rolename) + return nil + } else { + fmt.Printf("role %s is deployed\n", rolename) + } + slog.Debug("found role", "role", role) + + if conf.Debug { + // slog.Debug doesn't print it, for whatever reason + repr.Println(record) + } + + groups, err := getRoleMappingGroups(conf, rolename) + if err != nil { + return err + } + slog.Debug("group mappings", "groups", groups) + + // check cluster setting + clusters := []string{} + for _, cluster := range role.Cluster { + clusters = append(clusters, cluster.Name) + } + + // check index names+privs + indices := []string{} + privs := []string{} + for _, index := range role.Indices { + for _, name := range index.Names { + indices = append(indices, strings.ReplaceAll(name, "**", "*")) + } + + for _, priv := range index.Privileges { + privs = append(privs, priv.Name) + } + } + + // check kibana application space + spaces := []string{} + for _, app := range role.Applications { + for _, resource := range app.Resources { + if strings.Contains(resource, "space:") { + parts := strings.Split(resource, ":") + if len(parts) == 2 { + spaces = append(spaces, parts[1]) + } + } + } + } + + compareSlices("cluster_privilege", clusters, record.cluster_privilege) + compareSlices("ad_group", groups, record.ad_group) + compareSlices("index_name", indices, []string{record.index_name}) + compareSlices("index_privilege", privs, []string{record.index_privilege}) + compareSlices("space", spaces, []string{record.space}) + + return nil +}