From 60f1c6df29762a7c408162d59ca6ca7738e6eb8e Mon Sep 17 00:00:00 2001 From: Pieter Loubser Date: Fri, 14 Feb 2025 10:33:09 +0000 Subject: [PATCH] (#1268) Add mappings action to auth command mappings has `add`, `rm`, `list` and `info` commands. Add can take a --config flag pointing at a valid jwt, which it will then parse and extract the mappings from it. --- cli/auth_account_command.go | 241 ++++++++++++++++++++++++++++++++++++ go.sum | 2 - nats/auth_command_test.go | 209 +++++++++++++++++++++++++++++++ 3 files changed, 450 insertions(+), 2 deletions(-) create mode 100644 nats/auth_command_test.go diff --git a/cli/auth_account_command.go b/cli/auth_account_command.go index 213b3619..b2265534 100644 --- a/cli/auth_account_command.go +++ b/cli/auth_account_command.go @@ -24,6 +24,7 @@ import ( au "github.com/nats-io/natscli/internal/auth" iu "github.com/nats-io/natscli/internal/util" + "gopkg.in/yaml.v3" "github.com/AlecAivazis/survey/v2" "github.com/choria-io/fisk" @@ -102,6 +103,11 @@ type authAccountCommand struct { tags []string rmTags []string signingKey string + mapSource string + mapTarget string + mapWeight uint + mapCluster string + inputFile string } func configureAuthAccountCommand(auth commandHost) { @@ -280,6 +286,31 @@ func configureAuthAccountCommand(auth commandHost) { skrm.Flag("key", "The key to remove").StringVar(&c.skRole) skrm.Flag("operator", "Operator to act on").StringVar(&c.operatorName) skrm.Flag("force", "Removes without prompting").Short('f').UnNegatableBoolVar(&c.force) + + mappings := acct.Command("mappings", "Manage account level subject mapping and partitioning").Alias("m") + + mappingsaAdd := mappings.Command("add", "Add a new mapping").Alias("new").Alias("a").Action(c.mappingAddAction) + mappingsaAdd.Arg("account", "Account to create the mappings on").StringVar(&c.accountName) + mappingsaAdd.Arg("source", "The source subject of the mapping").StringVar(&c.mapSource) + mappingsaAdd.Arg("target", "The target subject of the mapping").StringVar(&c.mapTarget) + mappingsaAdd.Arg("weight", "The weight (%) of the mapping").Default("100").UintVar(&c.mapWeight) + mappingsaAdd.Arg("cluster", "Limit the mappings to a specific cluster").StringVar(&c.mapCluster) + mappingsaAdd.Flag("operator", "Operator to act on").StringVar(&c.operatorName) + mappingsaAdd.Flag("config", "json or yaml file to read configuration from").ExistingFileVar(&c.inputFile) + + mappingsls := mappings.Command("ls", "List mappings").Alias("list").Action(c.mappingListAction) + mappingsls.Arg("account", "Account to list the mappings from").StringVar(&c.accountName) + mappingsls.Flag("operator", "Operator to act on").StringVar(&c.operatorName) + + mappingsrm := mappings.Command("rm", "Remove a mapping").Action(c.mappingRmAction) + mappingsrm.Arg("account", "Account to remove the mappings from").StringVar(&c.accountName) + mappingsrm.Arg("source", "The source subject of the mapping").StringVar(&c.mapSource) + mappingsrm.Flag("operator", "Operator to act on").StringVar(&c.operatorName) + + mappingsinfo := mappings.Command("info", "Show information about a mapping").Alias("i").Alias("show").Alias("view").Action(c.mappingInfoAction) + mappingsinfo.Arg("account", "Account to inspect the mappings from").StringVar(&c.accountName) + mappingsinfo.Arg("source", "The source subject of the mapping").StringVar(&c.mapSource) + mappingsinfo.Flag("operator", "Operator to act on").StringVar(&c.operatorName) } func (c *authAccountCommand) selectAccount(pick bool) (*ab.AuthImpl, ab.Operator, ab.Account, error) { @@ -1101,3 +1132,213 @@ func (c *authAccountCommand) validTiers(acct ab.Account) []int8 { return tiers } + +func (c *authAccountCommand) loadMappingsConfig() (map[string][]ab.Mapping, error) { + if c.inputFile != "" { + f, err := os.ReadFile(c.inputFile) + if err != nil { + return nil, err + } + + var mappings map[string][]ab.Mapping + err = yaml.Unmarshal(f, &mappings) + if err != nil { + return nil, fmt.Errorf("unable to load config file: %s", err) + } + return mappings, nil + } + return nil, nil + +} + +func (c *authAccountCommand) mappingAddAction(_ *fisk.ParseContext) error { + var err error + mappings := map[string][]ab.Mapping{} + if c.inputFile != "" { + mappings, err = c.loadMappingsConfig() + if err != nil { + return err + } + } + + auth, _, acct, err := c.selectAccount(true) + if err != nil { + return err + } + + if c.inputFile == "" { + if c.mapSource == "" { + err := iu.AskOne(&survey.Input{ + Message: "Source subject", + Help: "The source subject of the mapping", + }, &c.mapSource, survey.WithValidator(survey.Required)) + if err != nil { + return err + } + } + + if c.mapTarget == "" { + err := iu.AskOne(&survey.Input{ + Message: "Target subject", + Help: "The target subject of the mapping", + }, &c.mapTarget, survey.WithValidator(survey.Required)) + if err != nil { + return err + } + } + + mapping := ab.Mapping{Subject: c.mapTarget, Weight: uint8(c.mapWeight)} + if c.mapCluster != "" { + mapping.Cluster = c.mapCluster + } + // check if there are mappings already set for the source + currentMappings := acct.SubjectMappings().Get(c.mapSource) + if len(currentMappings) > 0 { + // Check that we don't overwrite the current mapping + for _, m := range currentMappings { + if m.Subject == c.mapTarget { + return fmt.Errorf("mapping %s -> %s already exists", c.mapSource, c.mapTarget) + } + } + } + currentMappings = append(currentMappings, mapping) + mappings[c.mapSource] = currentMappings + } + + for subject, m := range mappings { + err = acct.SubjectMappings().Set(subject, m...) + if err != nil { + return err + } + } + + err = auth.Commit() + if err != nil { + return err + } + + return c.fShowMappings(os.Stdout, mappings) +} + +func (c *authAccountCommand) mappingInfoAction(_ *fisk.ParseContext) error { + _, _, acct, err := c.selectAccount(true) + if err != nil { + return err + } + + accountMappings := acct.SubjectMappings().List() + if len(accountMappings) == 0 { + fmt.Println("No mappings defined") + return nil + } + + if c.mapSource == "" { + err = iu.AskOne(&survey.Select{ + Message: "Select a mapping to inspect", + Options: accountMappings, + PageSize: iu.SelectPageSize(len(accountMappings)), + }, &c.mapSource) + if err != nil { + return err + } + } + + mappings := map[string][]ab.Mapping{ + c.mapSource: acct.SubjectMappings().Get(c.mapSource), + } + + return c.fShowMappings(os.Stdout, mappings) +} + +func (c *authAccountCommand) mappingListAction(_ *fisk.ParseContext) error { + _, _, acct, err := c.selectAccount(true) + if err != nil { + return err + } + + mappings := acct.SubjectMappings().List() + if len(mappings) == 0 { + fmt.Println("No mappings defined") + return nil + } + + tbl := iu.NewTableWriter(opts(), "Subject mappings for account %s", acct.Name()) + tbl.AddHeaders("Source Subject", "Target Subject", "Weight", "Cluster") + + for _, fromMapping := range acct.SubjectMappings().List() { + subjectMaps := acct.SubjectMappings().Get(fromMapping) + for _, m := range subjectMaps { + tbl.AddRow(fromMapping, m.Subject, m.Weight, m.Cluster) + } + } + + fmt.Println(tbl.Render()) + return nil +} + +func (c *authAccountCommand) mappingRmAction(_ *fisk.ParseContext) error { + auth, _, acct, err := c.selectAccount(true) + if err != nil { + return err + } + + mappings := acct.SubjectMappings().List() + if len(mappings) == 0 { + fmt.Println("No mappings defined") + return nil + } + + if c.mapSource == "" { + err = iu.AskOne(&survey.Select{ + Message: "Select a mapping to delete", + Options: mappings, + PageSize: iu.SelectPageSize(len(mappings)), + }, &c.mapSource) + if err != nil { + return err + } + } + + err = acct.SubjectMappings().Delete(c.mapSource) + if err != nil { + return err + } + + err = auth.Commit() + if err != nil { + return err + } + + fmt.Printf("Deleted mapping {%s}\n", c.mapSource) + return nil +} + +func (c *authAccountCommand) fShowMappings(w io.Writer, mappings map[string][]ab.Mapping) error { + out, err := c.showMappings(mappings) + if err != nil { + return err + } + + _, err = fmt.Fprintln(w, out) + return err +} + +func (c *authAccountCommand) showMappings(mappings map[string][]ab.Mapping) (string, error) { + cols := newColumns("Subject mappings") + cols.AddSectionTitle("Configuration") + for source, m := range mappings { + totalWeight := 0 + for _, wm := range m { + cols.AddRow("Source", source) + cols.AddRow("Target", wm.Subject) + cols.AddRow("Weight", wm.Weight) + cols.AddRow("Cluster", wm.Cluster) + cols.AddRow("", "") + totalWeight += int(wm.Weight) + } + cols.AddRow("Total weight:", totalWeight) + cols.AddRow("", "") + } + + return cols.Render() +} diff --git a/go.sum b/go.sum index 3ad0bb39..6c357f03 100644 --- a/go.sum +++ b/go.sum @@ -152,8 +152,6 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/synadia-io/jwt-auth-builder.go v0.0.6 h1:F3bTGWlKzWHwRqtTt35fRmhrxXLgkI8qz8QvvzxKSko= -github.com/synadia-io/jwt-auth-builder.go v0.0.6/go.mod h1:8WYR7+nLQcDMBpocuPgdFJ5/2UOr+HPll3qv+KNdGvs= github.com/synadia-io/jwt-auth-builder.go v0.0.7-0.20250307212657-0e3f1ee00864 h1:itO+DjIffRn+nN3jHxHNcCiJIsL1BMZF7p3wYeTN7xs= github.com/synadia-io/jwt-auth-builder.go v0.0.7-0.20250307212657-0e3f1ee00864/go.mod h1:8WYR7+nLQcDMBpocuPgdFJ5/2UOr+HPll3qv+KNdGvs= github.com/tylertreat/hdrhistogram-writer v0.0.0-20210816161836-2e440612a39f h1:SGznmvCovewbaSgBsHgdThtWsLj5aCLX/3ZXMLd1UD0= diff --git a/nats/auth_command_test.go b/nats/auth_command_test.go new file mode 100644 index 00000000..d1e2bd5b --- /dev/null +++ b/nats/auth_command_test.go @@ -0,0 +1,209 @@ +// Copyright 2025 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +const ( + TEST_DIR = "/tmp/natscli/auth_command_test" +) + +var ( + JSON = ` +{ + "test.a": [ + { + "subject": "test.b", + "weight": 100, + "cluster": "test_cluster" + } + ] +} +` + + YAML = ` +test.a: + - subject: test.b + weight: 100 + cluster: test_cluster +` +) + +func setup(operator, account string, t *testing.T) { + teardown(t) + err := os.Setenv("XDG_CONFIG_HOME", TEST_DIR) + if err != nil { + t.Error(err) + } + err = os.Setenv("XDG_DATA_HOME", TEST_DIR) + if err != nil { + t.Error(err) + } + + runNatsCli(t, fmt.Sprintf("auth operator add %s", operator)) + runNatsCli(t, fmt.Sprintf("auth account add --operator=%s --defaults %s", operator, account)) +} + +func teardown(t *testing.T) { + err := os.RemoveAll(TEST_DIR) + if err != nil { + t.Error(err) + } + err = os.Unsetenv("NSC_HOME") + if err != nil { + t.Error(err) + } +} + +func TestMapping(t *testing.T) { + t.Run("--add", func(t *testing.T) { + accountName, operatorName := "test_account", "test_operator" + setup(operatorName, accountName, t) + t.Cleanup(func() { + teardown(t) + }) + + fields := map[string]*regexp.Regexp{ + "source": regexp.MustCompile("Source: test.a"), + "target": regexp.MustCompile("Target: test.b"), + "weight": regexp.MustCompile("Weight: 100"), + "totalWeight": regexp.MustCompile("Total weight: 100"), + } + + output := runNatsCli(t, fmt.Sprintf("auth account mappings add %s test.a test.b 100 --operator=%s", accountName, operatorName)) + + for name, pattern := range fields { + if !pattern.Match(output) { + t.Errorf("%s value does not match expected %s", name, pattern) + } + } + }) + + t.Run("--add from config", func(t *testing.T) { + tests := []struct { + name string + fileExt string + data string + }{ + {"--add from config json", "json", JSON}, + {"--add from config yaml", "yaml", YAML}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + accountName, operatorName := "test_account", "test_operator" + setup(operatorName, accountName, t) + t.Cleanup(func() { teardown(t) }) + + fields := map[string]*regexp.Regexp{ + "source": regexp.MustCompile("Source: test.a"), + "target": regexp.MustCompile("Target: test.b"), + "weight": regexp.MustCompile("Weight: 100"), + "cluster": regexp.MustCompile("Cluster: test_cluster"), + "totalWeight": regexp.MustCompile("Total weight: 100"), + } + + fp := filepath.Join(TEST_DIR, fmt.Sprintf("test.%s", tt.fileExt)) + file, err := os.OpenFile(fp, os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + t.Fatalf("Error opening file: %s", err) + } + defer file.Close() + + _, err = file.WriteString(strings.TrimSpace(tt.data)) + if err != nil { + t.Fatalf("Error writing to file: %s", err) + } + + output := runNatsCli(t, fmt.Sprintf("auth account mappings add %s --operator=%s --config=%s", accountName, operatorName, fp)) + + for name, pattern := range fields { + if !pattern.Match(output) { + t.Errorf("%s value does not match expected %s", name, pattern) + } + } + }) + } + }) + t.Run("--ls", func(t *testing.T) { + accountName, operatorName := "test_account", "test_operator" + setup(operatorName, accountName, t) + t.Cleanup(func() { + teardown(t) + }) + + colums := map[string]*regexp.Regexp{ + "top": regexp.MustCompile("Subject mappings for account test_account"), + "middle": regexp.MustCompile("Source Subject │ Target Subject │ Weight │ Cluster"), + "bottom": regexp.MustCompile("test.a │ test.b │ 100"), + } + + runNatsCli(t, fmt.Sprintf("auth account mappings add %s test.a test.b 100 --operator=%s", accountName, operatorName)) + output := runNatsCli(t, fmt.Sprintf("auth account mappings ls %s --operator=%s", accountName, operatorName)) + + for name, pattern := range colums { + if !pattern.Match(output) { + t.Errorf("%s value does not match expected %s", name, pattern) + } + } + }) + + t.Run("--info", func(t *testing.T) { + accountName, operatorName := "test_account", "test_operator" + setup(operatorName, accountName, t) + t.Cleanup(func() { + teardown(t) + }) + + fields := map[string]*regexp.Regexp{ + "source": regexp.MustCompile("Source: test.a"), + "target": regexp.MustCompile("Target: test.b"), + "weight": regexp.MustCompile("Weight: 100"), + "totalWeight": regexp.MustCompile("Total weight: 100"), + } + + runNatsCli(t, fmt.Sprintf("auth account mappings add %s test.a test.b 100 --operator=%s", accountName, operatorName)) + output := runNatsCli(t, fmt.Sprintf("auth account mappings info %s test.a --operator=%s", accountName, operatorName)) + + for name, pattern := range fields { + if !pattern.Match(output) { + t.Errorf("%s value does not match expected %s", name, pattern) + } + } + }) + + t.Run("--delete", func(t *testing.T) { + accountName, operatorName := "test_account", "test_operator" + setup(operatorName, accountName, t) + t.Cleanup(func() { + teardown(t) + }) + + expected := regexp.MustCompile("Deleted mapping {test.a}") + + runNatsCli(t, fmt.Sprintf("auth account mappings add %s test.a test.b 100 --operator=%s", accountName, operatorName)) + output := runNatsCli(t, fmt.Sprintf("auth account mappings rm %s test.a --operator=%s", accountName, operatorName)) + + if !expected.Match(output) { + t.Errorf("failed to delete mapping: %s", output) + } + }) +}