diff --git a/acl_commands.go b/acl_commands.go index 06847be2..9cb800bb 100644 --- a/acl_commands.go +++ b/acl_commands.go @@ -4,8 +4,20 @@ import "context" type ACLCmdable interface { ACLDryRun(ctx context.Context, username string, command ...interface{}) *StringCmd + ACLLog(ctx context.Context, count int64) *ACLLogCmd ACLLogReset(ctx context.Context) *StatusCmd + + ACLSetUser(ctx context.Context, username string, rules ...string) *StatusCmd + ACLDelUser(ctx context.Context, username string) *IntCmd + ACLList(ctx context.Context) *StringSliceCmd + + ACLCat(ctx context.Context) *StringSliceCmd + ACLCatArgs(ctx context.Context, options *ACLCatArgs) *StringSliceCmd +} + +type ACLCatArgs struct { + Category string } func (c cmdable) ACLDryRun(ctx context.Context, username string, command ...interface{}) *StringCmd { @@ -33,3 +45,45 @@ func (c cmdable) ACLLogReset(ctx context.Context) *StatusCmd { _ = c(ctx, cmd) return cmd } + +func (c cmdable) ACLDelUser(ctx context.Context, username string) *IntCmd { + cmd := NewIntCmd(ctx, "acl", "deluser", username) + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) ACLSetUser(ctx context.Context, username string, rules ...string) *StatusCmd { + args := make([]interface{}, 3+len(rules)) + args[0] = "acl" + args[1] = "setuser" + args[2] = username + for i, rule := range rules { + args[i+3] = rule + } + cmd := NewStatusCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) ACLList(ctx context.Context) *StringSliceCmd { + cmd := NewStringSliceCmd(ctx, "acl", "list") + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) ACLCat(ctx context.Context) *StringSliceCmd { + cmd := NewStringSliceCmd(ctx, "acl", "cat") + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) ACLCatArgs(ctx context.Context, options *ACLCatArgs) *StringSliceCmd { + // if there is a category passed, build new cmd, if there isn't - use the ACLCat method + if options != nil && options.Category != "" { + cmd := NewStringSliceCmd(ctx, "acl", "cat", options.Category) + _ = c(ctx, cmd) + return cmd + } + + return c.ACLCat(ctx) +} diff --git a/acl_commands_test.go b/acl_commands_test.go new file mode 100644 index 00000000..84645583 --- /dev/null +++ b/acl_commands_test.go @@ -0,0 +1,449 @@ +package redis_test + +import ( + "context" + + "github.com/redis/go-redis/v9" + + . "github.com/bsm/ginkgo/v2" + . "github.com/bsm/gomega" +) + +var TestUserName string = "goredis" +var _ = Describe("ACL", func() { + var client *redis.Client + var ctx context.Context + + BeforeEach(func() { + ctx = context.Background() + opt := redisOptions() + client = redis.NewClient(opt) + }) + + It("should ACL LOG", Label("NonRedisEnterprise"), func() { + Expect(client.ACLLogReset(ctx).Err()).NotTo(HaveOccurred()) + err := client.Do(ctx, "acl", "setuser", "test", ">test", "on", "allkeys", "+get").Err() + Expect(err).NotTo(HaveOccurred()) + + clientAcl := redis.NewClient(redisOptions()) + clientAcl.Options().Username = "test" + clientAcl.Options().Password = "test" + clientAcl.Options().DB = 0 + _ = clientAcl.Set(ctx, "mystring", "foo", 0).Err() + _ = clientAcl.HSet(ctx, "myhash", "foo", "bar").Err() + _ = clientAcl.SAdd(ctx, "myset", "foo", "bar").Err() + + logEntries, err := client.ACLLog(ctx, 10).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(len(logEntries)).To(Equal(4)) + + for _, entry := range logEntries { + Expect(entry.Reason).To(Equal("command")) + Expect(entry.Context).To(Equal("toplevel")) + Expect(entry.Object).NotTo(BeEmpty()) + Expect(entry.Username).To(Equal("test")) + Expect(entry.AgeSeconds).To(BeNumerically(">=", 0)) + Expect(entry.ClientInfo).NotTo(BeNil()) + Expect(entry.EntryID).To(BeNumerically(">=", 0)) + Expect(entry.TimestampCreated).To(BeNumerically(">=", 0)) + Expect(entry.TimestampLastUpdated).To(BeNumerically(">=", 0)) + } + + limitedLogEntries, err := client.ACLLog(ctx, 2).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(len(limitedLogEntries)).To(Equal(2)) + + // cleanup after creating the user + err = client.Do(ctx, "acl", "deluser", "test").Err() + Expect(err).NotTo(HaveOccurred()) + }) + + It("should ACL LOG RESET", Label("NonRedisEnterprise"), func() { + // Call ACL LOG RESET + resetCmd := client.ACLLogReset(ctx) + Expect(resetCmd.Err()).NotTo(HaveOccurred()) + Expect(resetCmd.Val()).To(Equal("OK")) + + // Verify that the log is empty after the reset + logEntries, err := client.ACLLog(ctx, 10).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(len(logEntries)).To(Equal(0)) + }) + +}) +var _ = Describe("ACL user commands", Label("NonRedisEnterprise"), func() { + var client *redis.Client + var ctx context.Context + + BeforeEach(func() { + ctx = context.Background() + opt := redisOptions() + client = redis.NewClient(opt) + }) + + AfterEach(func() { + _, err := client.ACLDelUser(context.Background(), TestUserName).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(client.Close()).NotTo(HaveOccurred()) + }) + + It("list only default user", func() { + res, err := client.ACLList(ctx).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(res).To(HaveLen(1)) + Expect(res[0]).To(ContainSubstring("default")) + }) + + It("setuser and deluser", func() { + res, err := client.ACLList(ctx).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(res).To(HaveLen(1)) + Expect(res[0]).To(ContainSubstring("default")) + + add, err := client.ACLSetUser(ctx, TestUserName, "nopass", "on", "allkeys", "+set", "+get").Result() + Expect(err).NotTo(HaveOccurred()) + Expect(add).To(Equal("OK")) + + resAfter, err := client.ACLList(ctx).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(resAfter).To(HaveLen(2)) + Expect(resAfter[1]).To(ContainSubstring(TestUserName)) + + deletedN, err := client.ACLDelUser(ctx, TestUserName).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(deletedN).To(BeNumerically("==", 1)) + + resAfterDeletion, err := client.ACLList(ctx).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(resAfterDeletion).To(HaveLen(1)) + Expect(resAfterDeletion[0]).To(BeEquivalentTo(res[0])) + }) + + It("should acl dryrun", func() { + dryRun := client.ACLDryRun(ctx, "default", "get", "randomKey") + Expect(dryRun.Err()).NotTo(HaveOccurred()) + Expect(dryRun.Val()).To(Equal("OK")) + }) +}) + +var _ = Describe("ACL permissions", Label("NonRedisEnterprise"), func() { + var client *redis.Client + var ctx context.Context + + BeforeEach(func() { + ctx = context.Background() + opt := redisOptions() + opt.UnstableResp3 = true + client = redis.NewClient(opt) + }) + + AfterEach(func() { + _, err := client.ACLDelUser(context.Background(), TestUserName).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(client.Close()).NotTo(HaveOccurred()) + }) + + It("reset permissions", func() { + add, err := client.ACLSetUser(ctx, + TestUserName, + "reset", + "nopass", + "on", + ).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(add).To(Equal("OK")) + + connection := client.Conn() + authed, err := connection.AuthACL(ctx, TestUserName, "").Result() + Expect(err).NotTo(HaveOccurred()) + Expect(authed).To(Equal("OK")) + + _, err = connection.Get(ctx, "anykey").Result() + Expect(err).To(HaveOccurred()) + }) + + It("add write permissions", func() { + add, err := client.ACLSetUser(ctx, + TestUserName, + "reset", + "nopass", + "on", + "~*", + "+SET", + ).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(add).To(Equal("OK")) + + connection := client.Conn() + authed, err := connection.AuthACL(ctx, TestUserName, "").Result() + Expect(err).NotTo(HaveOccurred()) + Expect(authed).To(Equal("OK")) + + // can write + v, err := connection.Set(ctx, "anykey", "anyvalue", 0).Result() + Expect(err).ToNot(HaveOccurred()) + Expect(v).To(Equal("OK")) + + // but can't read + value, err := connection.Get(ctx, "anykey").Result() + Expect(err).To(HaveOccurred()) + Expect(value).To(BeEmpty()) + }) + + It("add read permissions", func() { + add, err := client.ACLSetUser(ctx, + TestUserName, + "reset", + "nopass", + "on", + "~*", + "+GET", + ).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(add).To(Equal("OK")) + + connection := client.Conn() + authed, err := connection.AuthACL(ctx, TestUserName, "").Result() + Expect(err).NotTo(HaveOccurred()) + Expect(authed).To(Equal("OK")) + + // can read + value, err := connection.Get(ctx, "anykey").Result() + Expect(err).ToNot(HaveOccurred()) + Expect(value).To(Equal("anyvalue")) + + // but can't delete + del, err := connection.Del(ctx, "anykey").Result() + Expect(err).To(HaveOccurred()) + Expect(del).ToNot(Equal(1)) + }) + + It("add del permissions", func() { + add, err := client.ACLSetUser(ctx, + TestUserName, + "reset", + "nopass", + "on", + "~*", + "+DEL", + ).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(add).To(Equal("OK")) + + connection := client.Conn() + authed, err := connection.AuthACL(ctx, TestUserName, "").Result() + Expect(err).NotTo(HaveOccurred()) + Expect(authed).To(Equal("OK")) + + // can read + del, err := connection.Del(ctx, "anykey").Result() + Expect(err).ToNot(HaveOccurred()) + Expect(del).To(BeEquivalentTo(1)) + }) + + It("set permissions for module commands", func() { + SkipBeforeRedisMajor(8, "permissions for modules are supported for Redis Version >=8") + Expect(client.FlushDB(ctx).Err()).NotTo(HaveOccurred()) + val, err := client.FTCreate(ctx, "txt", &redis.FTCreateOptions{}, &redis.FieldSchema{FieldName: "txt", FieldType: redis.SearchFieldTypeText}).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(BeEquivalentTo("OK")) + WaitForIndexing(client, "txt") + client.HSet(ctx, "doc1", "txt", "foo baz") + client.HSet(ctx, "doc2", "txt", "foo bar") + add, err := client.ACLSetUser(ctx, + TestUserName, + "reset", + "nopass", + "on", + "~*", + "+FT.SEARCH", + "-FT.DROPINDEX", + "+json.set", + "+json.get", + "-json.clear", + "+bf.reserve", + "-bf.info", + "+cf.reserve", + "+cms.initbydim", + "+topk.reserve", + "+tdigest.create", + "+ts.create", + "-ts.info", + ).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(add).To(Equal("OK")) + + c := client.Conn() + authed, err := c.AuthACL(ctx, TestUserName, "").Result() + Expect(err).NotTo(HaveOccurred()) + Expect(authed).To(Equal("OK")) + + // has perm for search + Expect(c.FTSearch(ctx, "txt", "foo ~bar").Err()).NotTo(HaveOccurred()) + + // no perm for dropindex + err = c.FTDropIndex(ctx, "txt").Err() + Expect(err).ToNot(BeEmpty()) + Expect(err.Error()).To(ContainSubstring("NOPERM")) + + // json set and get have perm + Expect(c.JSONSet(ctx, "foo", "$", "\"bar\"").Err()).NotTo(HaveOccurred()) + Expect(c.JSONGet(ctx, "foo", "$").Val()).To(BeEquivalentTo("[\"bar\"]")) + + // no perm for json clear + err = c.JSONClear(ctx, "foo", "$").Err() + Expect(err).ToNot(BeEmpty()) + Expect(err.Error()).To(ContainSubstring("NOPERM")) + + // perm for reserve + Expect(c.BFReserve(ctx, "bloom", 0.01, 100).Err()).NotTo(HaveOccurred()) + + // no perm for info + err = c.BFInfo(ctx, "bloom").Err() + Expect(err).ToNot(BeEmpty()) + Expect(err.Error()).To(ContainSubstring("NOPERM")) + + // perm for cf.reserve + Expect(c.CFReserve(ctx, "cfres", 100).Err()).NotTo(HaveOccurred()) + // perm for cms.initbydim + Expect(c.CMSInitByDim(ctx, "cmsdim", 100, 5).Err()).NotTo(HaveOccurred()) + // perm for topk.reserve + Expect(c.TopKReserve(ctx, "topk", 10).Err()).NotTo(HaveOccurred()) + // perm for tdigest.create + Expect(c.TDigestCreate(ctx, "tdc").Err()).NotTo(HaveOccurred()) + // perm for ts.create + Expect(c.TSCreate(ctx, "tsts").Err()).NotTo(HaveOccurred()) + // noperm for ts.info + err = c.TSInfo(ctx, "tsts").Err() + Expect(err).ToNot(BeEmpty()) + Expect(err.Error()).To(ContainSubstring("NOPERM")) + + Expect(client.FlushDB(ctx).Err()).NotTo(HaveOccurred()) + }) + + It("set permissions for module categories", func() { + SkipBeforeRedisMajor(8, "permissions for modules are supported for Redis Version >=8") + Expect(client.FlushDB(ctx).Err()).NotTo(HaveOccurred()) + val, err := client.FTCreate(ctx, "txt", &redis.FTCreateOptions{}, &redis.FieldSchema{FieldName: "txt", FieldType: redis.SearchFieldTypeText}).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(BeEquivalentTo("OK")) + WaitForIndexing(client, "txt") + client.HSet(ctx, "doc1", "txt", "foo baz") + client.HSet(ctx, "doc2", "txt", "foo bar") + add, err := client.ACLSetUser(ctx, + TestUserName, + "reset", + "nopass", + "on", + "~*", + "+@search", + "+@json", + "+@bloom", + "+@cuckoo", + "+@topk", + "+@cms", + "+@timeseries", + "+@tdigest", + ).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(add).To(Equal("OK")) + + c := client.Conn() + authed, err := c.AuthACL(ctx, TestUserName, "").Result() + Expect(err).NotTo(HaveOccurred()) + Expect(authed).To(Equal("OK")) + + // has perm for search + Expect(c.FTSearch(ctx, "txt", "foo ~bar").Err()).NotTo(HaveOccurred()) + // perm for dropindex + Expect(c.FTDropIndex(ctx, "txt").Err()).NotTo(HaveOccurred()) + // json set and get have perm + Expect(c.JSONSet(ctx, "foo", "$", "\"bar\"").Err()).NotTo(HaveOccurred()) + Expect(c.JSONGet(ctx, "foo", "$").Val()).To(BeEquivalentTo("[\"bar\"]")) + // perm for json clear + Expect(c.JSONClear(ctx, "foo", "$").Err()).NotTo(HaveOccurred()) + // perm for reserve + Expect(c.BFReserve(ctx, "bloom", 0.01, 100).Err()).NotTo(HaveOccurred()) + // perm for info + Expect(c.BFInfo(ctx, "bloom").Err()).NotTo(HaveOccurred()) + // perm for cf.reserve + Expect(c.CFReserve(ctx, "cfres", 100).Err()).NotTo(HaveOccurred()) + // perm for cms.initbydim + Expect(c.CMSInitByDim(ctx, "cmsdim", 100, 5).Err()).NotTo(HaveOccurred()) + // perm for topk.reserve + Expect(c.TopKReserve(ctx, "topk", 10).Err()).NotTo(HaveOccurred()) + // perm for tdigest.create + Expect(c.TDigestCreate(ctx, "tdc").Err()).NotTo(HaveOccurred()) + // perm for ts.create + Expect(c.TSCreate(ctx, "tsts").Err()).NotTo(HaveOccurred()) + // perm for ts.info + Expect(c.TSInfo(ctx, "tsts").Err()).NotTo(HaveOccurred()) + + Expect(client.FlushDB(ctx).Err()).NotTo(HaveOccurred()) + }) +}) + +var _ = Describe("ACL Categories", func() { + var client *redis.Client + var ctx context.Context + + BeforeEach(func() { + ctx = context.Background() + opt := redisOptions() + client = redis.NewClient(opt) + }) + + AfterEach(func() { + Expect(client.Close()).NotTo(HaveOccurred()) + }) + + It("lists acl categories and subcategories", func() { + res, err := client.ACLCat(ctx).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(len(res)).To(BeNumerically(">", 20)) + Expect(res).To(ContainElements( + "read", + "write", + "keyspace", + "dangerous", + "slow", + "set", + "sortedset", + "list", + "hash", + )) + + res, err = client.ACLCatArgs(ctx, &redis.ACLCatArgs{Category: "read"}).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(res).To(ContainElement("get")) + }) + + It("lists acl categories and subcategories with Modules", func() { + SkipBeforeRedisMajor(8, "modules are included in acl for redis version >= 8") + aclTestCase := map[string]string{ + "search": "FT.CREATE", + "bloom": "bf.add", + "json": "json.get", + "cuckoo": "cf.insert", + "cms": "cms.query", + "topk": "topk.list", + "tdigest": "tdigest.rank", + "timeseries": "ts.range", + } + var cats []interface{} + + for cat, subitem := range aclTestCase { + cats = append(cats, cat) + + res, err := client.ACLCatArgs(ctx, &redis.ACLCatArgs{ + Category: cat, + }).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(res).To(ContainElement(subitem)) + } + + res, err := client.ACLCat(ctx).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(res).To(ContainElements(cats...)) + }) +}) diff --git a/commands_test.go b/commands_test.go index dacc7f3d..404ffd02 100644 --- a/commands_test.go +++ b/commands_test.go @@ -211,13 +211,13 @@ var _ = Describe("Commands", func() { select { case <-done: Fail("BLPOP is not blocked.") - case <-time.After(2 * time.Second): + case <-time.After(1 * time.Second): // ok } killed := client.ClientKillByFilter(ctx, "MAXAGE", "1") Expect(killed.Err()).NotTo(HaveOccurred()) - Expect(killed.Val()).To(SatisfyAny(Equal(int64(2)), Equal(int64(3)))) + Expect(killed.Val()).To(SatisfyAny(Equal(int64(2)), Equal(int64(3)), Equal(int64(4)))) select { case <-done: @@ -2228,12 +2228,6 @@ var _ = Describe("Commands", func() { Expect(replace.Val()).To(Equal(int64(1))) }) - It("should acl dryrun", func() { - dryRun := client.ACLDryRun(ctx, "default", "get", "randomKey") - Expect(dryRun.Err()).NotTo(HaveOccurred()) - Expect(dryRun.Val()).To(Equal("OK")) - }) - It("should fail module loadex", Label("NonRedisEnterprise"), func() { dryRun := client.ModuleLoadex(ctx, &redis.ModuleLoadexConfig{ Path: "/path/to/non-existent-library.so", @@ -2281,51 +2275,6 @@ var _ = Describe("Commands", func() { Expect(args).To(Equal(expectedArgs)) }) - - It("should ACL LOG", Label("NonRedisEnterprise"), func() { - err := client.Do(ctx, "acl", "setuser", "test", ">test", "on", "allkeys", "+get").Err() - Expect(err).NotTo(HaveOccurred()) - - clientAcl := redis.NewClient(redisOptions()) - clientAcl.Options().Username = "test" - clientAcl.Options().Password = "test" - clientAcl.Options().DB = 0 - _ = clientAcl.Set(ctx, "mystring", "foo", 0).Err() - _ = clientAcl.HSet(ctx, "myhash", "foo", "bar").Err() - _ = clientAcl.SAdd(ctx, "myset", "foo", "bar").Err() - - logEntries, err := client.ACLLog(ctx, 10).Result() - Expect(err).NotTo(HaveOccurred()) - Expect(len(logEntries)).To(Equal(4)) - - for _, entry := range logEntries { - Expect(entry.Reason).To(Equal("command")) - Expect(entry.Context).To(Equal("toplevel")) - Expect(entry.Object).NotTo(BeEmpty()) - Expect(entry.Username).To(Equal("test")) - Expect(entry.AgeSeconds).To(BeNumerically(">=", 0)) - Expect(entry.ClientInfo).NotTo(BeNil()) - Expect(entry.EntryID).To(BeNumerically(">=", 0)) - Expect(entry.TimestampCreated).To(BeNumerically(">=", 0)) - Expect(entry.TimestampLastUpdated).To(BeNumerically(">=", 0)) - } - - limitedLogEntries, err := client.ACLLog(ctx, 2).Result() - Expect(err).NotTo(HaveOccurred()) - Expect(len(limitedLogEntries)).To(Equal(2)) - }) - - It("should ACL LOG RESET", Label("NonRedisEnterprise"), func() { - // Call ACL LOG RESET - resetCmd := client.ACLLogReset(ctx) - Expect(resetCmd.Err()).NotTo(HaveOccurred()) - Expect(resetCmd.Val()).To(Equal("OK")) - - // Verify that the log is empty after the reset - logEntries, err := client.ACLLog(ctx, 10).Result() - Expect(err).NotTo(HaveOccurred()) - Expect(len(logEntries)).To(Equal(0)) - }) }) Describe("hashes", func() {