/* 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] = []any{ 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 }