diff --git a/libs/resources/RespCommandsDocs.json b/libs/resources/RespCommandsDocs.json index 3a3c46bdcf..d3a3e33c62 100644 --- a/libs/resources/RespCommandsDocs.json +++ b/libs/resources/RespCommandsDocs.json @@ -38,6 +38,22 @@ } ] }, + { + "Command": "ACL_GETUSER", + "Name": "ACL|GETUSER", + "Summary": "Returns the rules defined for an ACL user.", + "Group": "Server", + "Complexity": "O(1) amortized time considering the typical user.", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "USERNAME", + "DisplayText": "username", + "Type": "String", + "ArgumentFlags": "Multiple" + } + ] + }, { "Command": "ACL_LIST", "Name": "ACL|LIST", diff --git a/libs/resources/RespCommandsInfo.json b/libs/resources/RespCommandsInfo.json index 1181f264f6..9fc087be8c 100644 --- a/libs/resources/RespCommandsInfo.json +++ b/libs/resources/RespCommandsInfo.json @@ -23,6 +23,17 @@ "response_policy:all_succeeded" ] }, + { + "Command": "ACL_GETUSER", + "Name": "ACL|GETUSER", + "Arity": -3, + "Flags": "Admin, Loading, NoScript, Stale", + "AclCategories": "Admin, Dangerous, Slow", + "Tips": [ + "request_policy:all_nodes", + "response_policy:all_succeeded" + ] + }, { "Command": "ACL_LIST", "Name": "ACL|LIST", diff --git a/libs/server/ACL/User.cs b/libs/server/ACL/User.cs index 699fc0ff5f..0c070cc80f 100644 --- a/libs/server/ACL/User.cs +++ b/libs/server/ACL/User.cs @@ -124,7 +124,7 @@ public void AddCategory(RespAclCategories category) /// /// Adds the given command to the user. - /// + /// /// If the command has subcommands, and no specific subcommand is indicated, adds all subcommands too. /// /// Command to add. @@ -246,7 +246,7 @@ public void RemoveCategory(RespAclCategories category) /// /// Removes the given command from the user. - /// + /// /// If the command has subcommands, and no specific subcommand is indicated, removes all subcommands too. /// /// Command to remove. @@ -420,6 +420,33 @@ public string DescribeUser() return stringBuilder.ToString(); } + /// + /// Returns flags for the . + /// + /// A of representing flags for the user. + public List GetFlags() + { + return new() { IsEnabled ? "on" : "off" }; + } + + /// + /// Returns password hashes for the . + /// + /// A of representing password hashes for the user. + public List GetPasswordHashes() + { + return _passwordHashes.Select(hash => $"#{hash}").ToList(); + } + + /// + /// Returns a containing the enabled commands. + /// + /// A containing the enabled commands. + public string GetEnabledCommandsDescription() + { + return _enabledCommands.Description; + } + /// /// Determine the command / sub command pairs that are associated with this command information entries /// @@ -448,7 +475,7 @@ internal static IEnumerable DetermineCommandDetails(IReadOnlyList /// Check to see if any tokens from a description can be removed without modifying the effective permissions. - /// + /// /// This is an expensive method, but ACL modifications are rare enough it's hopefully not a problem. /// private static string RationalizeACLDescription(CommandPermissionSet set, string description) @@ -492,7 +519,7 @@ internal CommandPermissionSet CopyCommandPermissionSet() /// /// A set of all allowed _passwordHashes for the user. - /// + /// /// NOTE: HashSet is not thread-safe, so accesses need to be synchronized /// readonly HashSet _passwordHashes = []; diff --git a/libs/server/Resp/ACLCommands.cs b/libs/server/Resp/ACLCommands.cs index 150cf31528..107f7add16 100644 --- a/libs/server/Resp/ACLCommands.cs +++ b/libs/server/Resp/ACLCommands.cs @@ -366,5 +366,90 @@ private bool NetworkAclSave() return true; } + + /// + /// Processes ACL GETUSER subcommand. + /// + /// true if parsing succeeded correctly, false if not all tokens could be consumed and further processing is necessary. + private bool NetworkAclGetUser() + { + // Have to have at least the username + if (parseState.Count != 1) + { + while (!RespWriteUtils.TryWriteError($"ERR Unknown subcommand or wrong number of arguments for ACL GETUSER.", ref dcurr, dend)) + SendAndReset(); + } + else + { + if (!ValidateACLAuthenticator()) + return true; + + var aclAuthenticator = (GarnetACLAuthenticator)_authenticator; + User user = null; + + try + { + user = aclAuthenticator + .GetAccessControlList() + .GetUser(parseState.GetString(0)); + } + catch (ACLException exception) + { + logger?.LogDebug("ACLException: {message}", exception.Message); + + // Abort command execution + while (!RespWriteUtils.TryWriteError($"ERR {exception.Message}", ref dcurr, dend)) + SendAndReset(); + + return true; + } + + if (user is null) + { + while (!RespWriteUtils.TryWriteNull(ref dcurr, dend)) + SendAndReset(); + } + else + { + while (!RespWriteUtils.TryWriteArrayLength(6, ref dcurr, dend)) + SendAndReset(); + + var flags = user.GetFlags(); + var passwordHashes = user.GetPasswordHashes(); + + while (!RespWriteUtils.TryWriteAsciiBulkString("flags", ref dcurr, dend)) + SendAndReset(); + + while (!RespWriteUtils.TryWriteArrayLength(flags.Count, ref dcurr, dend)) + SendAndReset(); + + foreach (var flag in flags) + { + while (!RespWriteUtils.TryWriteAsciiBulkString(flag, ref dcurr, dend)) + SendAndReset(); + } + + while (!RespWriteUtils.TryWriteAsciiBulkString("passwords", ref dcurr, dend)) + SendAndReset(); + + while (!RespWriteUtils.TryWriteArrayLength(passwordHashes.Count, ref dcurr, dend)) + SendAndReset(); + + foreach (var passwordHash in passwordHashes) + { + while (!RespWriteUtils.TryWriteAsciiBulkString(passwordHash, ref dcurr, dend)) + SendAndReset(); + } + + while (!RespWriteUtils.TryWriteAsciiBulkString("commands", ref dcurr, dend)) + SendAndReset(); + + while (!RespWriteUtils.TryWriteAsciiBulkString(user.GetEnabledCommandsDescription(), ref dcurr, dend)) + SendAndReset(); + } + } + + return true; + } } } \ No newline at end of file diff --git a/libs/server/Resp/AdminCommands.cs b/libs/server/Resp/AdminCommands.cs index 7a2e6fd000..0cb10af43e 100644 --- a/libs/server/Resp/AdminCommands.cs +++ b/libs/server/Resp/AdminCommands.cs @@ -2,7 +2,6 @@ // Licensed under the MIT license. using System; -using System.Collections; using System.Collections.Generic; using System.Diagnostics; using System.IO; @@ -56,6 +55,7 @@ RespCommand.MIGRATE or RespCommand.HCOLLECT => NetworkHCOLLECT(ref storageApi), RespCommand.MONITOR => NetworkMonitor(), RespCommand.ACL_DELUSER => NetworkAclDelUser(), + RespCommand.ACL_GETUSER => NetworkAclGetUser(), RespCommand.ACL_LIST => NetworkAclList(), RespCommand.ACL_LOAD => NetworkAclLoad(), RespCommand.ACL_SETUSER => NetworkAclSetUser(), @@ -126,7 +126,7 @@ internal bool CheckACLPermissions(RespCommand cmd) /// /// Handle ACL or NoScript failures. - /// + /// /// Failing should be rare, and is not important for performance so hide this behind /// a method call to keep icache pressure down /// diff --git a/libs/server/Resp/CmdStrings.cs b/libs/server/Resp/CmdStrings.cs index 9af70e001a..debf458baf 100644 --- a/libs/server/Resp/CmdStrings.cs +++ b/libs/server/Resp/CmdStrings.cs @@ -307,6 +307,7 @@ static partial class CmdStrings // subcommand parsing strings public static ReadOnlySpan CAT => "CAT"u8; public static ReadOnlySpan DELUSER => "DELUSER"u8; + public static ReadOnlySpan GETUSER => "GETUSER"u8; public static ReadOnlySpan LOAD => "LOAD"u8; public static ReadOnlySpan LOADCS => "LOADCS"u8; public static ReadOnlySpan SETUSER => "SETUSER"u8; @@ -344,7 +345,7 @@ static partial class CmdStrings public static ReadOnlySpan NO => "NO"u8; // Cluster subcommands which are internal and thus undocumented - // + // // Because these are internal, they have lower case property names public static ReadOnlySpan gossip => "GOSSIP"u8; public static ReadOnlySpan myparentid => "MYPARENTID"u8; diff --git a/libs/server/Resp/Parser/RespCommand.cs b/libs/server/Resp/Parser/RespCommand.cs index 627c2aec6b..ac95f1227d 100644 --- a/libs/server/Resp/Parser/RespCommand.cs +++ b/libs/server/Resp/Parser/RespCommand.cs @@ -274,6 +274,7 @@ public enum RespCommand : ushort ACL, ACL_CAT, ACL_DELUSER, + ACL_GETUSER, ACL_LIST, ACL_LOAD, ACL_SAVE, @@ -377,6 +378,7 @@ public static class RespCommandExtensions // ACL RespCommand.ACL_CAT, RespCommand.ACL_DELUSER, + RespCommand.ACL_GETUSER, RespCommand.ACL_LIST, RespCommand.ACL_LOAD, RespCommand.ACL_SAVE, @@ -1641,7 +1643,7 @@ private RespCommand FastParseArrayCommand(ref int count, ref ReadOnlySpan /// /// Parses the receive buffer, starting from the current read head, for all command names that are /// not covered by FastParseArrayCommand() and advances the read head to the end of the command name. - /// + /// /// NOTE: Assumes the input command names have already been converted to upper-case. /// /// Reference to the number of remaining tokens in the packet. Will be reduced to number of command arguments. @@ -2122,6 +2124,10 @@ private RespCommand SlowParseCommand(ref int count, ref ReadOnlySpan speci { return RespCommand.ACL_DELUSER; } + else if (subCommand.SequenceEqual(CmdStrings.GETUSER)) + { + return RespCommand.ACL_GETUSER; + } else if (subCommand.SequenceEqual(CmdStrings.LIST)) { return RespCommand.ACL_LIST; @@ -2326,7 +2332,7 @@ private void HandleAofCommitMode(RespCommand cmd) if (txnManager.state == TxnState.Started) return; - /* + /* If a previous command marked AOF for blocking we should not change AOF blocking flag. If no previous command marked AOF for blocking, then we only change AOF flag to block if the current command is AOF dependent. diff --git a/test/Garnet.test/Resp/ACL/GetUserTests.cs b/test/Garnet.test/Resp/ACL/GetUserTests.cs new file mode 100644 index 0000000000..0087f974d7 --- /dev/null +++ b/test/Garnet.test/Resp/ACL/GetUserTests.cs @@ -0,0 +1,204 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using NUnit.Framework; +using NUnit.Framework.Legacy; +using StackExchange.Redis; + +namespace Garnet.test.Resp.ACL +{ + /// + /// Tests for ACL GETUSER command. + /// + [TestFixture] + internal class GetUserTests : AclTest + { + private const string DefaultUserName = "default"; + + private const string MultiCommandList = "+get +set +setex +decr +decrby +incr +incrby +del +unlink +flushdb +latency"; + + private const int CommandResultArrayLength = 6; + + /* + * Use ordinal values when retrieving command results to ensure consistency of client contract. + * Changes requiring modifications to these ordinal values are likely breaking for clients. + */ + private const int FlagsPropertyNameIndex = 0; + + private const int FlagsPropertyValueIndex = 1; + + private const int PasswordPropertyNameIndex = 2; + + private const int PasswordPropertyValueIndex = 3; + + private const int CommandsPropertyNameIndex = 4; + + private const int CommandsPropertyValueIndex = 5; + + /// + /// Tests that ACL GETUSER shows the default user. + /// + [Test] + public async Task GetUserTest() + { + server = TestUtils.CreateGarnetServer(TestUtils.MethodTestDir, useAcl: true); + server.Start(); + + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + RedisResult commandResult = await db.ExecuteAsync("ACL", "GETUSER", DefaultUserName); + + ClassicAssert.NotNull(commandResult); + ClassicAssert.AreEqual(CommandResultArrayLength, commandResult.Length); + + ClassicAssert.AreEqual("flags", commandResult[FlagsPropertyNameIndex].ToString()); + ClassicAssert.AreEqual("on", commandResult[FlagsPropertyValueIndex][0].ToString()); + + ClassicAssert.AreEqual("passwords", commandResult[PasswordPropertyNameIndex].ToString()); + ClassicAssert.AreEqual(0, commandResult[PasswordPropertyValueIndex].Length); + + ClassicAssert.AreEqual("commands", commandResult[CommandsPropertyNameIndex].ToString()); + ClassicAssert.AreEqual("+@all", commandResult[CommandsPropertyValueIndex].ToString()); + } + + /// + /// Tests that ACL GETUSER returns null when user is not found. + /// + [Test] + public async Task GetUserNotFoundTest() + { + server = TestUtils.CreateGarnetServer(TestUtils.MethodTestDir, useAcl: true); + server.Start(); + + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + + ClassicAssert.IsTrue((await db.ExecuteAsync("ACL", "GETUSER", $"!{DefaultUserName}")).IsNull); + } + + /// + /// Tests that ACL GETUSER shows various users and their appropriate ACL information. + /// Note: Multi-segment ACLs are consolidated, ex: -@all +get becomes +get. Matching ACL LIST behavior. + /// + [Test, Sequential] + [TestCase(TestUserA, "on", DummyPassword, "+@admin", "+@admin")] + [TestCase(TestUserA, "off", "nopass", "+get", "+get")] + [TestCase(TestUserA, "", "", "+@all", "+@all")] + [TestCase(TestUserA, "on", "nopass", "-@all +get", "+get")] + public async Task GetUserAclTest( + string userName, + string enabled, + string credential, + string commands, + string expectedCommands) + { + if (!string.IsNullOrWhiteSpace(credential) && credential != "nopass") + { + credential = $">{credential}"; + } + + StringBuilder sb = new StringBuilder($"user default on nopass +@all {Environment.NewLine}"); + sb.AppendLine($"user {userName} {enabled} {credential} {commands}"); + + // Create an input with user definition (including default) + var configurationFile = Path.Join(TestUtils.MethodTestDir, "users.acl"); + File.WriteAllText(configurationFile, sb.ToString()); + + // Start up Garnet with a defined default user password + server = TestUtils.CreateGarnetServer(TestUtils.MethodTestDir, useAcl: true, aclFile: configurationFile, defaultPassword: DummyPassword); + server.Start(); + + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig(authUsername: DefaultUserName)); + var db = redis.GetDatabase(0); + + RedisResult commandResult = await db.ExecuteAsync("ACL", "GETUSER", userName); + + ClassicAssert.NotNull(commandResult); + ClassicAssert.AreEqual(CommandResultArrayLength, commandResult.Length); + + ClassicAssert.AreEqual("flags", commandResult[FlagsPropertyNameIndex].ToString()); + + if (string.IsNullOrWhiteSpace(enabled)) + { + enabled = "off"; + } + + ClassicAssert.AreEqual(enabled, commandResult[FlagsPropertyValueIndex][0].ToString()); + + ClassicAssert.AreEqual("passwords", commandResult[PasswordPropertyNameIndex].ToString()); + + bool found = ContainsDefaultPassword(commandResult); + + if (!found && !string.IsNullOrWhiteSpace(credential) && credential != "nopass") + { + ClassicAssert.Fail("Credential not found"); + } + + ClassicAssert.AreEqual("commands", commandResult[CommandsPropertyNameIndex].ToString()); + ClassicAssert.AreEqual(expectedCommands, commandResult[CommandsPropertyValueIndex].ToString()); + } + + /// + /// Tests that ACL GETUSER retrieves correct user and their appropriate ACL information when multiple users + /// are present. + /// + [Test, Sequential] + public async Task GetUserMultiUserTest() + { + StringBuilder sb = new StringBuilder($"user {DefaultUserName} on nopass +@all {Environment.NewLine}"); + sb.AppendLine($"user {TestUserA} on >{DummyPassword} {MultiCommandList} {Environment.NewLine}"); + sb.AppendLine($"user {TestUserB} on >{DummyPasswordB} +set {Environment.NewLine}"); + + // Create an input with 3 user definitions (including default) + var configurationFile = Path.Join(TestUtils.MethodTestDir, "users.acl"); + File.WriteAllText(configurationFile, sb.ToString()); + + // Start up Garnet with a defined default user password + server = TestUtils.CreateGarnetServer(TestUtils.MethodTestDir, useAcl: true, aclFile: configurationFile, defaultPassword: DummyPassword); + server.Start(); + + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig(authUsername: DefaultUserName)); + var db = redis.GetDatabase(0); + + RedisResult commandResult = await db.ExecuteAsync("ACL", "GETUSER", TestUserA); + + ClassicAssert.NotNull(commandResult); + ClassicAssert.AreEqual(CommandResultArrayLength, commandResult.Length); + + ClassicAssert.AreEqual("flags", commandResult[FlagsPropertyNameIndex].ToString()); + + ClassicAssert.AreEqual("on", commandResult[FlagsPropertyValueIndex][0].ToString()); + + ClassicAssert.AreEqual("passwords", commandResult[PasswordPropertyNameIndex].ToString()); + var found = ContainsDefaultPassword(commandResult); + + if (!found) + { + ClassicAssert.Fail("Credential not found"); + } + + ClassicAssert.AreEqual("commands", commandResult[CommandsPropertyNameIndex].ToString()); + ClassicAssert.AreEqual(MultiCommandList, commandResult[CommandsPropertyValueIndex].ToString()); + } + + private static bool ContainsDefaultPassword(RedisResult commandResult) + { + bool found = false; + foreach (RedisResult hash in (RedisResult[])commandResult[PasswordPropertyValueIndex]) + { + if (hash.ToString() == $"#{DummyPasswordHash}") + { + found = true; + } + } + + return found; + } + } +} \ No newline at end of file diff --git a/test/Garnet.test/Resp/ACL/RespCommandTests.cs b/test/Garnet.test/Resp/ACL/RespCommandTests.cs index 91ea4fb2e8..a365ff5311 100644 --- a/test/Garnet.test/Resp/ACL/RespCommandTests.cs +++ b/test/Garnet.test/Resp/ACL/RespCommandTests.cs @@ -177,6 +177,22 @@ async Task DoAclDelUserMultiAsync(GarnetClient client) } } + [Test] + public async Task AclGetUserACLsAsync() + { + await CheckCommandsAsync( + "ACL GETUSER", + [DoAclGetUserAsync], + skipPermitted: true + ); + + static async Task DoAclGetUserAsync(GarnetClient client) + { + // ACL GETUSER returns an array of arrays, which GarnetClient doesn't deal with + await client.ExecuteForStringResultAsync("ACL", ["GETUSER", "default"]); + } + } + [Test] public async Task AclListACLsAsync() { @@ -6663,7 +6679,7 @@ async Task DoRestoreAsync(GarnetClient client) { var payload = new byte[] { - 0x00, // value type + 0x00, // value type 0x03, // length of payload 0x76, 0x61, 0x6C, // 'v', 'a', 'l' 0x0B, 0x00, // RDB version @@ -7112,7 +7128,7 @@ static async Task GetUserAsync(GarnetClient client, string user) /// /// Returns true if no AUTH failure. /// Returns false AUTH failure. - /// + /// /// Throws if anything else. /// private static async Task CheckAuthFailureAsync(Func act) diff --git a/test/Garnet.test/RespCommandTests.cs b/test/Garnet.test/RespCommandTests.cs index 175fa9cb18..885192d167 100644 --- a/test/Garnet.test/RespCommandTests.cs +++ b/test/Garnet.test/RespCommandTests.cs @@ -436,6 +436,7 @@ public void AofIndependentCommandsTest() // ACL RespCommand.ACL_CAT, RespCommand.ACL_DELUSER, + RespCommand.ACL_GETUSER, RespCommand.ACL_LIST, RespCommand.ACL_LOAD, RespCommand.ACL_SAVE,