Files
go-redis/acl_commands_test.go
Nedyalko Dyakov c9092ec492 fix(auth): make empty-password AUTH opt-in (#4194)
* fix(auth): make empty-password AUTH opt-in

#4161 made a Username with an empty Password send AUTH user "". Redis
OSS accepts it (the nopass default user takes any password), but Redis
Enterprise and Redis Cloud databases with unauthenticated access can
reject it with WRONGPASS, so configs with a username and no password
stopped connecting.

Restore the earlier default (no AUTH without a password) and add
Options.AuthEmptyPassword (auth_empty_password=true) to opt in, on all
options types and credential providers at connect time. Public Hello
again sends AUTH only with a password. The client-side cache namespace
uses the ACL user the connection runs as.

Refs #4161

* fix(auth): keep CSC namespace per username

A Username that sends no AUTH runs as the default user, or as a
TLS-certificate user. Mapping it to the default namespace let clients
with different certificates share cache entries. It now gets its own
namespace per Username ("noauth <name>", which no ACL user can match).

Test: delete the ACL user before closing the admin client, so the user
does not leak into the ACL specs.

* fix(auth): split sentinel empty-password opt-in

Sentinel and data-node credentials are independent, so one flag for
both could force AUTH user "" onto Redis Enterprise data nodes to
authenticate a nopass sentinel user. FailoverOptions and
UniversalOptions gain SentinelAuthEmptyPassword
(sentinel_auth_empty_password=true); AuthEmptyPassword now covers the
master/replica connections only.
2026-10-26 23:32:17 +02:00

501 lines
14 KiB
Go

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"))
res, err = client.ACLUsers(ctx).Result()
Expect(err).NotTo(HaveOccurred())
Expect(res).To(HaveLen(1))
Expect(res[0]).To(Equal("default"))
res1, err := client.ACLWhoAmI(ctx).Result()
Expect(err).NotTo(HaveOccurred())
Expect(res1).To(Equal("default"))
})
It("gen password", func() {
password, err := client.ACLGenPass(ctx, 0).Result()
Expect(err).NotTo(HaveOccurred())
Expect(password).NotTo(BeEmpty())
})
It("should authenticate a nopass user with only a username", func() {
add, err := client.ACLSetUser(ctx, TestUserName, "on", "nopass", "+acl").Result()
Expect(err).NotTo(HaveOccurred())
Expect(add).To(Equal("OK"))
for _, protocol := range []int{3, 2} {
for _, authEmptyPassword := range []bool{false, true} {
opt := redisOptions()
opt.Username = TestUserName
opt.Password = ""
opt.Protocol = protocol
opt.AuthEmptyPassword = authEmptyPassword
userClient := redis.NewClient(opt)
whoami, err := userClient.ACLWhoAmI(ctx).Result()
Expect(userClient.Close()).NotTo(HaveOccurred())
Expect(err).NotTo(HaveOccurred())
// By default a username without a password sends no AUTH.
want := "default"
if authEmptyPassword {
want = TestUserName
}
Expect(whoami).To(Equal(want),
"protocol %d, AuthEmptyPassword %t", protocol, authEmptyPassword)
}
}
})
It("gen password with length", func() {
bit := 128
password, err := client.ACLGenPass(ctx, bit).Result()
Expect(err).NotTo(HaveOccurred())
Expect(password).NotTo(BeEmpty())
Expect(len(password)).To(Equal(bit / 4))
})
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() {
SkipBeforeRedisVersion("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).To(HaveOccurred())
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).To(HaveOccurred())
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).To(HaveOccurred())
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).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("NOPERM"))
Expect(client.FlushDB(ctx).Err()).NotTo(HaveOccurred())
})
It("set permissions for module categories", func() {
SkipBeforeRedisVersion("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() {
SkipBeforeRedisVersion("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...))
})
})