package loaddata import ( "bytes" "context" "encoding/json" "errors" "fmt" "os" "slices" "strings" "github.com/peterbourgon/ff/v3" "github.com/shurcooL/graphql" "edge-infra.dev/pkg/edge/api/client" "edge-infra.dev/pkg/edge/api/graph/model" "edge-infra.dev/pkg/edge/edgeadmin/commands/operatorintervention/edgeextension" "edge-infra.dev/pkg/edge/edgeadmin/commands/operatorintervention/format" "edge-infra.dev/pkg/edge/edgecli" "edge-infra.dev/pkg/edge/edgecli/flagutil" "edge-infra.dev/pkg/lib/cli/command" "edge-infra.dev/pkg/lib/cli/rags" ) // config represents the configuration structure for loading data. type config struct { RoleMappings map[string][]string `json:"ROLE_MAPPING"` Rules map[string][]string `json:"RULES"` } func (cfg config) Validate() error { var err error if len(cfg.RoleMappings) == 0 && len(cfg.Rules) == 0 { err = errors.Join(err, errors.New("empty config")) } for role, privileges := range cfg.RoleMappings { // Every role must have at least one privilege if len(privileges) == 0 { err = errors.Join(err, fmt.Errorf("role %q has no privileges", role)) } for _, privilege := range privileges { // Privileges must be non-nil if privilege == "" { err = errors.Join(err, fmt.Errorf("role %q has empty privilege", role)) } } } for privilege, commands := range cfg.Rules { // Every privilege must have at least one command if len(commands) == 0 { err = errors.Join(err, fmt.Errorf("privilege %q has no commands", privilege)) } for _, command := range commands { // Commands must be non-nil if command == "" { err = errors.Join(err, fmt.Errorf("privilege %q has empty command", privilege)) } } } return err } const ( longHelpString = ` This command loads Operator Intervention configuration from a file and stores it in the database. This allows for bulk configuration of role mappings and rules. Any privileges or commands added through this operation will be added automatically to the database. Duplicate entries will be ignored. The configuration file should contain role mappings and rules in JSON format as follows: { "ROLE_MAPPING": { "EDGE_BANNER_ADMIN": [ "ea-read", "ea-banner-admin" ] }, "RULES": { "ea-write": [ "ls", "cat" ], "ea-banner-admin": [ "kubectl" ] } } Note that the top level keys are optional. This file can then be saved to and passed to the binary using the --file flag as follows: ~/edge-infra$ edgeadmin operatorintervention loaddata --file /path/to/file.json ` ) // NewCmd creates a new command for loading OperatorIntervention configuration data. func NewCmd(cfg *edgecli.Config) *command.Command { var ( edge = &edgeextension.Ext{Cfg: cfg} ) var cmd *command.Command cmd = &command.Command{ ShortUsage: "edgeadmin operatorintervention loaddata", ShortHelp: "loads operator intervention configuration from a file to the database", LongHelp: longHelpString, Options: []ff.Option{}, Flags: []*rags.Rag{ { Name: flagutil.LoadData, Usage: "path to file with rolemappings and rules", Value: &rags.String{}, Required: true, }, }, Extensions: []command.Extension{edge}, Exec: func(ctx context.Context, _ []string) error { configPath := flagutil.GetStringFlag(cmd.Rags, flagutil.LoadData) config, err := loadConfig(configPath) if err != nil { fmt.Printf("error loading config \n") return err } err = loadData(ctx, edge.Client, config) if err != nil { fmt.Println("an error occurred while loading data") return err } return nil }, } return cmd } // loadConfig loads the configuration from the specified file path. // returns an empty config and an error if the file is not found or the data is invalid. func loadConfig(configPath string) (config, error) { data, err := os.ReadFile(configPath) if err != nil { return config{}, err } var conf config decoder := json.NewDecoder(bytes.NewReader(data)) // Strict decoder that enforces the config file only having expected fields. decoder.DisallowUnknownFields() if err = decoder.Decode(&conf); err != nil { return config{}, err } if decoder.More() { return config{}, errors.New("error multiple objects in config file") } if err := conf.Validate(); err != nil { return config{}, fmt.Errorf("error invalid config: %w", err) } return conf, nil } // createVariables is used to create a map which can be used as the variables // argument of a mutation call when applying all rules and role mappings. // We don't want to make the mutation call if the user has not supplied the // appropriate key in the configuration file, so include skip variables which // can be used to tell the server to not process specific mutations. func createVariables(conf config) map[string]interface{} { // Build up the fulll list of all privileges and commands to be added by // discovering each value referenced in the role mappings and rules var allPrivs []string var allCommands []string // Build up the role mapping data type that the mutation expects roleMappings := make([]*model.UpdateOperatorInterventionRoleMappingInput, 0, len(conf.RoleMappings)) for role, privileges := range conf.RoleMappings { // Collect all referenced privileges allPrivs = append(allPrivs, privileges...) // Add the role mapping roleMappings = append(roleMappings, &model.UpdateOperatorInterventionRoleMappingInput{ Role: role, Privileges: convertToPrivilegeInputs(privileges), }) } rules := make([]model.UpdateOperatorInterventionRuleInput, 0, len(conf.Rules)) for privilege, commands := range conf.Rules { // Collect all referenced privileges and commands allPrivs = append(allPrivs, privilege) allCommands = append(allCommands, commands...) // Add the rule rules = append(rules, model.UpdateOperatorInterventionRuleInput{ Privilege: &model.OperatorInterventionPrivilegeInput{ Name: privilege, }, Commands: convertToCommandInputs(commands), }) } // Ensure there are no duplicate privileges to be added slices.Sort(allPrivs) allPrivs = slices.Compact(allPrivs) // From the collected privileges build up the mutation input type. privileges := make([]model.OperatorInterventionPrivilegeInput, 0, len(allPrivs)) for _, priv := range allPrivs { privileges = append(privileges, model.OperatorInterventionPrivilegeInput{ Name: priv, }) } // Ensure there are no duplicate commands to be added slices.Sort(allCommands) allCommands = slices.Compact(allCommands) // From the collected commands build up the muatation input type. commands := make([]model.OperatorInterventionCommandInput, 0, len(allCommands)) for _, com := range allCommands { commands = append(commands, model.OperatorInterventionCommandInput{ Name: com, }) } return map[string]interface{}{ // Skip mutations if there is no data to add "skipCommands": graphql.Boolean(len(commands) == 0), "skipPrivileges": graphql.Boolean(len(privileges) == 0), "skipRules": graphql.Boolean(len(rules) == 0), "skipRoleMappings": graphql.Boolean(len(roleMappings) == 0), "roleMappings": roleMappings, "privileges": privileges, "commands": commands, "rules": rules, } } // converts a slice of commands into a slice of the model.OperatorInterventionCommandInput // type func convertToCommandInputs(commands []string) []*model.OperatorInterventionCommandInput { out := []*model.OperatorInterventionCommandInput{} for _, command := range commands { out = append(out, &model.OperatorInterventionCommandInput{ Name: command, }) } return out } // converts a slice of privileges into a slice of the model.OperatorInterventionPrivilegeInput // type func convertToPrivilegeInputs(privileges []string) []*model.OperatorInterventionPrivilegeInput { out := []*model.OperatorInterventionPrivilegeInput{} for _, privilege := range privileges { out = append(out, &model.OperatorInterventionPrivilegeInput{ Name: privilege, }) } return out } // oiLoadDataMutation is a struct which can be used to call all mutations for // the loaddata command in a single request to the server. The order is // important here - we need to add all commands and privileges before we add the // roles or rules, otherwise there is a chance we run into a db conflict type oiLoadDataMutation struct { CreateOperatorInterventionCommands struct { model.CreateOperatorInterventionCommandResponse } `graphql:"createOperatorInterventionCommands(commands: $commands) @skip(if: $skipCommands)"` CreateOperatorInterventionPrivileges struct { model.CreateOperatorInterventionPrivilegeResponse } `graphql:"createOperatorInterventionPrivileges(privileges: $privileges) @skip(if: $skipPrivileges)"` UpdateOperatorInterventionRules struct { model.UpdateOperatorInterventionRuleResponse } `graphql:"updateOperatorInterventionRules(rules: $rules) @skip(if: $skipRules)"` UpdateOperatorInterventionRoleMappings struct { model.UpdateOperatorInterventionRoleMappingResponse } `graphql:"updateOperatorInterventionRoleMappings(roleMappings: $roleMappings) @skip(if: $skipRoleMappings)"` } func loadData(ctx context.Context, cl *client.EdgeClient, conf config) error { variables := createVariables(conf) var mutation oiLoadDataMutation err := cl.Mutate(ctx, &mutation, variables) if err != nil { return err } output, err := generateAllOutput(variables, mutation) // error from generateAllOutput is only used to indicate if we should exit // the binary with a non-zero exit code, it doesn't include significant // information that we would like to display to the user. Display the user // facing response which includes lots of details first before returning err fmt.Print(output) return err } // generateAllOutput is used to build up the output string which includes all // details from all executed mutations. It uses the skipXXXX variables to // determin if the mutation would have been executed on the server. It then // generates an output string with all details of errors. func generateAllOutput(variables map[string]interface{}, mutation oiLoadDataMutation) (string, error) { var failed bool var output []string skipComamands := variables["skipCommands"] if skip, ok := skipComamands.(graphql.Boolean); ok && bool(!skip) { if len(mutation.CreateOperatorInterventionCommands.Errors) != 0 { failed = true } output = append(output, format.GenerateApplyOutput("Commands", mutation.CreateOperatorInterventionCommands.Errors)) } skipPrivileges := variables["skipPrivileges"] if skip, ok := skipPrivileges.(graphql.Boolean); ok && bool(!skip) { if len(mutation.CreateOperatorInterventionPrivileges.Errors) != 0 { failed = true } output = append(output, format.GenerateApplyOutput("Privileges", mutation.CreateOperatorInterventionPrivileges.Errors)) } skipRules := variables["skipRules"] if skip, ok := skipRules.(graphql.Boolean); ok && bool(!skip) { if len(mutation.UpdateOperatorInterventionRules.Errors) != 0 { failed = true } output = append(output, format.GenerateApplyOutput("Rules", mutation.UpdateOperatorInterventionRules.Errors)) } skipRoleMappings := variables["skipRoleMappings"] if skip, ok := skipRoleMappings.(graphql.Boolean); ok && bool(!skip) { if len(mutation.UpdateOperatorInterventionRoleMappings.Errors) != 0 { failed = true } output = append(output, format.GenerateApplyOutput("Role Mappings", mutation.UpdateOperatorInterventionRoleMappings.Errors)) } var err error if failed { err = fmt.Errorf("error during mutation") } return strings.Join(output, "\n"), err }