From 8fc030946f6a88e9cedb845b8a0b91e6593b10ac Mon Sep 17 00:00:00 2001 From: atakavci Date: Tue, 14 Jan 2025 14:07:43 +0300 Subject: [PATCH] add support for ACL commands --- src/StackExchange.Redis/APITypes/ACLRules.cs | 572 ++++++++++++++++++ .../APITypes/ACLRulesBuilder.cs | 462 ++++++++++++++ src/StackExchange.Redis/Enums/RedisCommand.cs | 3 +- src/StackExchange.Redis/Interfaces/IServer.cs | 148 +++++ src/StackExchange.Redis/Message.cs | 79 +++ .../PublicAPI/PublicAPI.Shipped.txt | 112 ++++ src/StackExchange.Redis/RedisLiterals.cs | 9 + src/StackExchange.Redis/RedisServer.cs | 120 ++++ src/StackExchange.Redis/ResultProcessor.cs | 38 ++ .../ACLIntegrationTests.cs | 135 +++++ tests/StackExchange.Redis.Tests/ACLTests.cs | 407 +++++++++++++ 11 files changed, 2084 insertions(+), 1 deletion(-) create mode 100644 src/StackExchange.Redis/APITypes/ACLRules.cs create mode 100644 src/StackExchange.Redis/APITypes/ACLRulesBuilder.cs create mode 100644 tests/StackExchange.Redis.Tests/ACLIntegrationTests.cs create mode 100644 tests/StackExchange.Redis.Tests/ACLTests.cs diff --git a/src/StackExchange.Redis/APITypes/ACLRules.cs b/src/StackExchange.Redis/APITypes/ACLRules.cs new file mode 100644 index 000000000..cee0ccaee --- /dev/null +++ b/src/StackExchange.Redis/APITypes/ACLRules.cs @@ -0,0 +1,572 @@ +using System.Collections.Generic; + +namespace StackExchange.Redis; + +/// +/// To control Access Control List config of individual users with ACL SETUSER command. +/// +/// +/// +public class ACLRules +{ + /// + /// Initializes a new instance of the class. + /// + /// The ACL user rules. + /// The ACL command rules. + /// The ACL selector rules. + public ACLRules( + ACLUserRules? aclUserRules, + ACLCommandRules? aclCommandRules, + ACLSelectorRules[]? aclSelectorRules) + { + AclUserRules = aclUserRules; + AclCommandRules = aclCommandRules; + AclSelectorRules = aclSelectorRules; + } + + /// + /// Gets the ACL user rules. + /// + public readonly ACLUserRules? AclUserRules; + + /// + /// Gets the ACL command rules. + /// + public readonly ACLCommandRules? AclCommandRules; + + /// + /// Gets the ACL selector rules. + /// + public readonly ACLSelectorRules[]? AclSelectorRules; + + /// + /// Converts the ACL rules to Redis values. + /// + /// An array of Redis values representing the ACL rules. + internal RedisValue[] ToRedisValues() + { + var redisValues = new List(); + AclUserRules?.AppendTo(redisValues); + AclCommandRules?.AppendTo(redisValues); + + if (AclSelectorRules is not null) + { + foreach (var rules in AclSelectorRules) + { + rules.AppendTo(redisValues); + } + } + return redisValues.ToArray(); + } +} + +/// +/// Represents the ACL user rules. +/// +public class ACLUserRules +{ + /// + /// Initializes a new instance of the class. + /// + /// If set to true, resets the user. + /// If set to true, no password is required. + /// If set to true, resets the password. + /// The state of the user. + /// The passwords to set. + /// The passwords to remove. + /// The hashed passwords to set. + /// The hashed passwords to remove. + /// If set to true, clears the selectors. + public ACLUserRules( + bool resetUser, + bool noPass, + bool resetPass, + ACLUserState? userState, + string[]? passwordsToSet, + string[]? passwordsToRemove, + string[]? hashedPasswordsToSet, + string[]? hashedPasswordsToRemove, + bool clearSelectors) + { + ResetUser = resetUser; + NoPass = noPass; + ResetPass = resetPass; + UserState = userState; + PasswordsToSet = passwordsToSet; + PasswordsToRemove = passwordsToRemove; + HashedPasswordsToSet = hashedPasswordsToSet; + HashedPasswordsToRemove = hashedPasswordsToRemove; + ClearSelectors = clearSelectors; + } + + /// + /// Gets a value indicating whether the user is reset. + /// + public readonly bool ResetUser; + + /// + /// Gets a value indicating whether no password is required. + /// + public readonly bool NoPass; + + /// + /// Gets a value indicating whether the password is reset. + /// + public readonly bool ResetPass; + + /// + /// Gets the state of the user. + /// + public readonly ACLUserState? UserState; + + /// + /// Gets the passwords to set. + /// + public readonly string[]? PasswordsToSet; + + /// + /// Gets the passwords to remove. + /// + public readonly string[]? PasswordsToRemove; + + /// + /// Gets the hashed passwords to set. + /// + public readonly string[]? HashedPasswordsToSet; + + /// + /// Gets the hashed passwords to remove. + /// + public readonly string[]? HashedPasswordsToRemove; + + /// + /// Gets a value indicating whether the selectors are cleared. + /// + public readonly bool ClearSelectors; + + /// + /// Appends the ACL user rules to the specified list of Redis values. + /// + /// The list of Redis values. + internal void AppendTo(List redisValues) + { + if (ResetUser) + { + redisValues.Add(RedisLiterals.RESET); + } + if (NoPass) + { + redisValues.Add(RedisLiterals.NOPASS); + } + if (ResetPass) + { + redisValues.Add(RedisLiterals.RESETPASS); + } + if (UserState.HasValue) + { + redisValues.Add(UserState.ToString()); + } + if (PasswordsToSet is not null) + { + foreach (var password in PasswordsToSet) + { + redisValues.Add(">" + password); + } + } + if (PasswordsToRemove is not null) + { + foreach (var password in PasswordsToRemove) + { + redisValues.Add("<" + password); + } + } + if (HashedPasswordsToSet is not null) + { + foreach (var password in HashedPasswordsToSet) + { + redisValues.Add("#" + password); + } + } + if (HashedPasswordsToRemove is not null) + { + foreach (var password in HashedPasswordsToRemove) + { + redisValues.Add("!" + password); + } + } + if (ClearSelectors) + { + redisValues.Add(RedisLiterals.CLEARSELECTORS); + } + } +} + +/// +/// Represents the ACL command rules. +/// +public class ACLCommandRules +{ + /// + /// Initializes a new instance of the class. + /// + /// The commands rule. + /// The commands allowed. + /// The commands disallowed. + /// The categories allowed. + /// The categories disallowed. + /// The keys rule. + /// The keys allowed patterns. + /// The keys allowed read-for patterns. + /// The keys allowed write-for patterns. + /// The pub/sub rule. + /// The pub/sub allow channels. + public ACLCommandRules( + ACLCommandsRule? commandsRule, + string[]? commandsAllowed, + string[]? commandsDisallowed, + string[]? categoriesAllowed, + string[]? categoriesDisallowed, + ACLKeysRule? keysRule, + string[]? keysAllowedPatterns, + string[]? keysAllowedReadForPatterns, + string[]? keysAllowedWriteForPatterns, + ACLPubSubRule? pubSubRule, + string[]? pubSubAllowChannels) + { + CommandsRule = commandsRule; + CommandsAllowed = commandsAllowed; + CommandsDisallowed = commandsDisallowed; + CategoriesAllowed = categoriesAllowed; + CategoriesDisallowed = categoriesDisallowed; + KeysRule = keysRule; + KeysAllowedPatterns = keysAllowedPatterns; + KeysAllowedReadForPatterns = keysAllowedReadForPatterns; + KeysAllowedWriteForPatterns = keysAllowedWriteForPatterns; + PubSubRule = pubSubRule; + PubSubAllowChannels = pubSubAllowChannels; + } + + /// + /// Gets the commands rule. + /// + public readonly ACLCommandsRule? CommandsRule; + + /// + /// Gets the commands allowed. + /// + public readonly string[]? CommandsAllowed; + + /// + /// Gets the commands disallowed. + /// + public readonly string[]? CommandsDisallowed; + + /// + /// Gets the categories allowed. + /// + public readonly string[]? CategoriesAllowed; + + /// + /// Gets the categories disallowed. + /// + public readonly string[]? CategoriesDisallowed; + + /// + /// Gets the keys rule. + /// + public readonly ACLKeysRule? KeysRule; + + /// + /// Gets the keys allowed patterns. + /// + public readonly string[]? KeysAllowedPatterns; + + /// + /// Gets the keys allowed read-for patterns. + /// + public readonly string[]? KeysAllowedReadForPatterns; + + /// + /// Gets the keys allowed write-for patterns. + /// + public readonly string[]? KeysAllowedWriteForPatterns; + + /// + /// Gets the pub/sub rule. + /// + public readonly ACLPubSubRule? PubSubRule; + + /// + /// Gets the pub/sub allow channels. + /// + public readonly string[]? PubSubAllowChannels; + + /// + /// Appends the ACL command rules to the specified list of Redis values. + /// + /// The list of Redis values. + internal void AppendTo(List redisValues) + { + if (CommandsRule.HasValue) + { + redisValues.Add(CommandsRule.ToString()); + } + if (CommandsAllowed is not null) + { + foreach (var command in CommandsAllowed) + { + redisValues.Add(RedisLiterals.PlusSymbol + command); + } + } + if (CommandsDisallowed is not null) + { + foreach (var command in CommandsDisallowed) + { + redisValues.Add(RedisLiterals.MinusSymbol + command); + } + } + if (CategoriesAllowed is not null) + { + foreach (var category in CategoriesAllowed) + { + redisValues.Add("+@" + category); + } + } + if (CategoriesDisallowed is not null) + { + foreach (var category in CategoriesDisallowed) + { + redisValues.Add("-@" + category); + } + } + if (KeysRule.HasValue) + { + redisValues.Add(KeysRule.ToString()); + } + if (KeysAllowedPatterns is not null) + { + foreach (var pattern in KeysAllowedPatterns) + { + redisValues.Add("~" + pattern); + } + } + if (KeysAllowedReadForPatterns is not null) + { + foreach (var pattern in KeysAllowedReadForPatterns) + { + redisValues.Add("%R~" + pattern); + } + } + if (KeysAllowedWriteForPatterns is not null) + { + foreach (var pattern in KeysAllowedWriteForPatterns) + { + redisValues.Add("%W~" + pattern); + } + } + if (PubSubRule.HasValue) + { + redisValues.Add(PubSubRule.ToString()); + } + if (PubSubAllowChannels is not null) + { + foreach (var channel in PubSubAllowChannels) + { + redisValues.Add("&" + channel); + } + } + } +} + +/// +/// Represents the ACL selector rules. +/// +public class ACLSelectorRules +{ + /// + /// Initializes a new instance of the class. + /// + /// The commands allowed. + /// The commands disallowed. + /// The categories allowed. + /// The categories disallowed. + /// The keys allowed patterns. + /// The keys allowed read-for patterns. + /// The keys allowed write-for patterns. + public ACLSelectorRules( + string[]? commandsAllowed, + string[]? commandsDisallowed, + string[]? categoriesAllowed, + string[]? categoriesDisallowed, + string[]? keysAllowedPatterns, + string[]? keysAllowedReadForPatterns, + string[]? keysAllowedWriteForPatterns) + { + CommandsAllowed = commandsAllowed; + CommandsDisallowed = commandsDisallowed; + CategoriesAllowed = categoriesAllowed; + CategoriesDisallowed = categoriesDisallowed; + KeysAllowedPatterns = keysAllowedPatterns; + KeysAllowedReadForPatterns = keysAllowedReadForPatterns; + KeysAllowedWriteForPatterns = keysAllowedWriteForPatterns; + } + + /// + /// Gets the commands allowed. + /// + public readonly string[]? CommandsAllowed; + + /// + /// Gets the commands disallowed. + /// + public readonly string[]? CommandsDisallowed; + + /// + /// Gets the categories allowed. + /// + public readonly string[]? CategoriesAllowed; + + /// + /// Gets the categories disallowed. + /// + public readonly string[]? CategoriesDisallowed; + + /// + /// Gets the keys allowed patterns. + /// + public readonly string[]? KeysAllowedPatterns; + + /// + /// Gets the keys allowed read-for patterns. + /// + public readonly string[]? KeysAllowedReadForPatterns; + + /// + /// Gets the keys allowed write-for patterns. + /// + public readonly string[]? KeysAllowedWriteForPatterns; + + /// + /// Appends the ACL selector rules to the specified list of Redis values. + /// + /// The list of Redis values. + internal void AppendTo(List redisValues) + { + redisValues.Add("("); + if (CommandsAllowed is not null) + { + foreach (var command in CommandsAllowed) + { + redisValues.Add("+" + command); + } + } + if (CommandsDisallowed is not null) + { + foreach (var command in CommandsDisallowed) + { + redisValues.Add("-" + command); + } + } + if (CategoriesAllowed is not null) + { + foreach (var category in CategoriesAllowed) + { + redisValues.Add("+@" + category); + } + } + if (CategoriesDisallowed is not null) + { + foreach (var category in CategoriesDisallowed) + { + redisValues.Add("-@" + category); + } + } + if (KeysAllowedPatterns is not null) + { + foreach (var pattern in KeysAllowedPatterns) + { + redisValues.Add("~" + pattern); + } + } + if (KeysAllowedReadForPatterns is not null) + { + foreach (var pattern in KeysAllowedReadForPatterns) + { + redisValues.Add("%R~" + pattern); + } + } + if (KeysAllowedWriteForPatterns is not null) + { + foreach (var pattern in KeysAllowedWriteForPatterns) + { + redisValues.Add("%W~" + pattern); + } + } + redisValues.Add(")"); + } +} + +/// +/// Represents the state of an ACL user. +/// +public enum ACLUserState +{ + /// + /// The user is on. + /// + ON, + + /// + /// The user is off. + /// + OFF, +} + +/// +/// Represents the ACL commands rule. +/// +public enum ACLCommandsRule +{ + /// + /// All commands are allowed. + /// + ALLCOMMANDS, + + /// + /// No commands are allowed. + /// + NOCOMMANDS, +} + +/// +/// Represents the ACL keys rule. +/// +public enum ACLKeysRule +{ + /// + /// All keys are allowed. + /// + ALLKEYS, + + /// + /// Keys are reset. + /// + RESETKEYS, +} + +/// +/// Represents the ACL pub/sub rule. +/// +public enum ACLPubSubRule +{ + /// + /// All channels are allowed. + /// + ALLCHANNELS, + + /// + /// Channels are reset. + /// + RESETCHANNELS, +} diff --git a/src/StackExchange.Redis/APITypes/ACLRulesBuilder.cs b/src/StackExchange.Redis/APITypes/ACLRulesBuilder.cs new file mode 100644 index 000000000..69c2b601f --- /dev/null +++ b/src/StackExchange.Redis/APITypes/ACLRulesBuilder.cs @@ -0,0 +1,462 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace StackExchange.Redis; + +/// +/// Main Builder for ACLRules. +/// +public class ACLRulesBuilder +{ + private ACLUserRulesBuilder? _aCLUserRulesBuilder; + private ACLCommandRulesBuilder? _aCLCommandRulesBuilder; + private List? _aCLSelectorRulesBuilderList; + + /// + /// Adds ACL user rules. + /// + /// The action to build ACL user rules. + /// The current instance of . + public ACLRulesBuilder WithACLUserRules(Action buildAction) + { + buildAction(_aCLUserRulesBuilder ??= new ACLUserRulesBuilder()); + return this; + } + + /// + /// Adds ACL command rules. + /// + /// The action to build ACL command rules. + /// The current instance of . + public ACLRulesBuilder WithACLCommandRules(Action buildAction) + { + buildAction(_aCLCommandRulesBuilder ??= new ACLCommandRulesBuilder()); + return this; + } + + /// + /// Appends ACL selector rules. + /// + /// The action to build ACL selector rules. + /// The current instance of . + public ACLRulesBuilder AppendACLSelectorRules(Action buildAction) + { + _aCLSelectorRulesBuilderList ??= new List(); + var newSelectorRule = new ACLSelectorRulesBuilder(); + buildAction(newSelectorRule); + _aCLSelectorRulesBuilderList.Add(newSelectorRule); + return this; + } + + /// + /// Builds the ACL rules. + /// + /// The built . + public ACLRules Build() + { + return new ACLRules( + _aCLUserRulesBuilder?.Build(), + _aCLCommandRulesBuilder?.Build(), + _aCLSelectorRulesBuilderList?.Select(item => item.Build()).ToArray()); + } +} + +/// +/// Builder for ACLUserRules. +/// +public class ACLUserRulesBuilder +{ + private bool _resetUser = false; + private bool _noPass = false; + private bool _resetPass = false; + private ACLUserState? _userState; + private string[]? _passwordsToSet; + private string[]? _passwordsToRemove; + private string[]? _hashedPasswordsToSet; + private string[]? _hashedPasswordsToRemove; + private bool _clearSelectors = false; + + /// + /// Resets the user. + /// + /// If set to true, resets the user. + /// The current instance of . + public ACLUserRulesBuilder ResetUser(bool resetUser) + { + _resetUser = resetUser; + return this; + } + + /// + /// Sets the no pass flag. + /// + /// If set to true, sets the no pass flag. + /// The current instance of . + public ACLUserRulesBuilder NoPass(bool noPass) + { + _noPass = noPass; + return this; + } + + /// + /// Resets the password. + /// + /// If set to true, resets the password. + /// The current instance of . + public ACLUserRulesBuilder ResetPass(bool resetPass) + { + _resetPass = resetPass; + return this; + } + + /// + /// Sets the user state. + /// + /// The user state. + /// The current instance of . + public ACLUserRulesBuilder UserState(ACLUserState? userState) + { + _userState = userState; + return this; + } + + /// + /// Sets the passwords to set. + /// + /// The passwords to set. + /// The current instance of . + public ACLUserRulesBuilder PasswordsToSet(params string[] passwords) + { + _passwordsToSet = passwords; + return this; + } + + /// + /// Sets the passwords to remove. + /// + /// The passwords to remove. + /// The current instance of . + public ACLUserRulesBuilder PasswordsToRemove(params string[] passwords) + { + _passwordsToRemove = passwords; + return this; + } + + /// + /// Sets the hashed passwords to set. + /// + /// The hashed passwords to set. + /// The current instance of . + public ACLUserRulesBuilder HashedPasswordsToSet(params string[] hashedPasswords) + { + _hashedPasswordsToSet = hashedPasswords; + return this; + } + + /// + /// Sets the hashed passwords to remove. + /// + /// The hashed passwords to remove. + /// The current instance of . + public ACLUserRulesBuilder HashedPasswordsToRemove(params string[] hashedPasswords) + { + _hashedPasswordsToRemove = hashedPasswords; + return this; + } + + /// + /// Clears the selectors. + /// + /// If set to true, clears the selectors. + /// The current instance of . + public ACLUserRulesBuilder ClearSelectors(bool clearSelectors) + { + _clearSelectors = clearSelectors; + return this; + } + + /// + /// Builds the ACL user rules. + /// + /// The built . + public ACLUserRules Build() + { + return new ACLUserRules( + _resetUser, + _noPass, + _resetPass, + _userState, + _passwordsToSet, + _passwordsToRemove, + _hashedPasswordsToSet, + _hashedPasswordsToRemove, + _clearSelectors); + } +} + +/// +/// Builder for ACLCommandRules. +/// +public class ACLCommandRulesBuilder +{ + private ACLCommandsRule? _commandsRule; + private string[]? _commandsAllowed; + private string[]? _commandsDisallowed; + private string[]? _categoriesAllowed; + private string[]? _categoriesDisallowed; + private ACLKeysRule? _keysRule; + private string[]? _keysAllowedPatterns; + private string[]? _keysAllowedReadForPatterns; + private string[]? _keysAllowedWriteForPatterns; + private ACLPubSubRule? _pubSubRule; + private string[]? _pubSubAllowChannels; + + /// + /// Sets the commands rule. + /// + /// The commands rule. + /// The current instance of . + public ACLCommandRulesBuilder CommandsRule(ACLCommandsRule? commandsRule) + { + _commandsRule = commandsRule; + return this; + } + + /// + /// Sets the commands allowed. + /// + /// The commands allowed. + /// The current instance of . + public ACLCommandRulesBuilder CommandsAllowed(params string[] commands) + { + _commandsAllowed = commands; + return this; + } + + /// + /// Sets the commands disallowed. + /// + /// The commands disallowed. + /// The current instance of . + public ACLCommandRulesBuilder CommandsDisallowed(params string[] commands) + { + _commandsDisallowed = commands; + return this; + } + + /// + /// Sets the categories allowed. + /// + /// The categories allowed. + /// The current instance of . + public ACLCommandRulesBuilder CategoriesAllowed(params string[] categories) + { + _categoriesAllowed = categories; + return this; + } + + /// + /// Sets the categories disallowed. + /// + /// The categories disallowed. + /// The current instance of . + public ACLCommandRulesBuilder CategoriesDisallowed(params string[] categories) + { + _categoriesDisallowed = categories; + return this; + } + + /// + /// Sets the keys rule. + /// + /// The keys rule. + /// The current instance of . + public ACLCommandRulesBuilder KeysRule(ACLKeysRule? keysRule) + { + _keysRule = keysRule; + return this; + } + + /// + /// Sets the keys allowed patterns. + /// + /// The keys allowed patterns. + /// The current instance of . + public ACLCommandRulesBuilder KeysAllowedPatterns(params string[] patterns) + { + _keysAllowedPatterns = patterns; + return this; + } + + /// + /// Sets the keys allowed read for patterns. + /// + /// The keys allowed read for patterns. + /// The current instance of . + public ACLCommandRulesBuilder KeysAllowedReadForPatterns(params string[] patterns) + { + _keysAllowedReadForPatterns = patterns; + return this; + } + + /// + /// Sets the keys allowed write for patterns. + /// + /// The keys allowed write for patterns. + /// The current instance of . + public ACLCommandRulesBuilder KeysAllowedWriteForPatterns(params string[] patterns) + { + _keysAllowedWriteForPatterns = patterns; + return this; + } + + /// + /// Sets the pub/sub rule. + /// + /// The pub/sub rule. + /// The current instance of . + public ACLCommandRulesBuilder PubSubRule(ACLPubSubRule? pubSubRule) + { + _pubSubRule = pubSubRule; + return this; + } + + /// + /// Sets the pub/sub allow channels. + /// + /// The pub/sub allow channels. + /// The current instance of . + public ACLCommandRulesBuilder PubSubAllowChannels(params string[] channels) + { + _pubSubAllowChannels = channels; + return this; + } + + /// + /// Builds the ACL command rules. + /// + /// The built . + public ACLCommandRules Build() + { + return new ACLCommandRules( + _commandsRule, + _commandsAllowed, + _commandsDisallowed, + _categoriesAllowed, + _categoriesDisallowed, + _keysRule, + _keysAllowedPatterns, + _keysAllowedReadForPatterns, + _keysAllowedWriteForPatterns, + _pubSubRule, + _pubSubAllowChannels); + } +} + +/// +/// Builder for ACLSelectorRules. +/// +public class ACLSelectorRulesBuilder +{ + private string[]? _commandsAllowed; + private string[]? _commandsDisallowed; + private string[]? _categoriesAllowed; + private string[]? _categoriesDisallowed; + private string[]? _keysAllowedPatterns; + private string[]? _keysAllowedReadForPatterns; + private string[]? _keysAllowedWriteForPatterns; + + /// + /// Sets the commands allowed. + /// + /// The commands allowed. + /// The current instance of . + public ACLSelectorRulesBuilder CommandsAllowed(params string[] commands) + { + _commandsAllowed = commands; + return this; + } + + /// + /// Sets the commands disallowed. + /// + /// The commands disallowed. + /// The current instance of . + public ACLSelectorRulesBuilder CommandsDisallowed(params string[] commands) + { + _commandsDisallowed = commands; + return this; + } + + /// + /// Sets the categories allowed. + /// + /// The categories allowed. + /// The current instance of . + public ACLSelectorRulesBuilder CategoriesAllowed(params string[] categories) + { + _categoriesAllowed = categories; + return this; + } + + /// + /// Sets the categories disallowed. + /// + /// The categories disallowed. + /// The current instance of . + public ACLSelectorRulesBuilder CategoriesDisallowed(params string[] categories) + { + _categoriesDisallowed = categories; + return this; + } + + /// + /// Sets the keys allowed patterns. + /// + /// The keys allowed patterns. + /// The current instance of . + public ACLSelectorRulesBuilder KeysAllowedPatterns(params string[] patterns) + { + _keysAllowedPatterns = patterns; + return this; + } + + /// + /// Sets the keys allowed read for patterns. + /// + /// The keys allowed read for patterns. + /// The current instance of . + public ACLSelectorRulesBuilder KeysAllowedReadForPatterns(params string[] patterns) + { + _keysAllowedReadForPatterns = patterns; + return this; + } + + /// + /// Sets the keys allowed write for patterns. + /// + /// The keys allowed write for patterns. + /// The current instance of . + public ACLSelectorRulesBuilder KeysAllowedWriteForPatterns(params string[] patterns) + { + _keysAllowedWriteForPatterns = patterns; + return this; + } + + /// + /// Builds the ACL selector rules. + /// + /// The built . + public ACLSelectorRules Build() + { + return new ACLSelectorRules( + _commandsAllowed, + _commandsDisallowed, + _categoriesAllowed, + _categoriesDisallowed, + _keysAllowedPatterns, + _keysAllowedReadForPatterns, + _keysAllowedWriteForPatterns); + } +} diff --git a/src/StackExchange.Redis/Enums/RedisCommand.cs b/src/StackExchange.Redis/Enums/RedisCommand.cs index a4647d7eb..e3c022dda 100644 --- a/src/StackExchange.Redis/Enums/RedisCommand.cs +++ b/src/StackExchange.Redis/Enums/RedisCommand.cs @@ -5,7 +5,7 @@ namespace StackExchange.Redis; internal enum RedisCommand { NONE, // must be first for "zero reasons" - + ACL, APPEND, ASKING, AUTH, @@ -358,6 +358,7 @@ internal static bool IsPrimaryOnly(this RedisCommand command) return true; // Commands that can be issued anywhere case RedisCommand.NONE: + case RedisCommand.ACL: case RedisCommand.ASKING: case RedisCommand.AUTH: case RedisCommand.BGREWRITEAOF: diff --git a/src/StackExchange.Redis/Interfaces/IServer.cs b/src/StackExchange.Redis/Interfaces/IServer.cs index fad2d4232..df3d271c9 100644 --- a/src/StackExchange.Redis/Interfaces/IServer.cs +++ b/src/StackExchange.Redis/Interfaces/IServer.cs @@ -77,6 +77,154 @@ public partial interface IServer : IRedis /// int DatabaseCount { get; } + /// + /// Gets the categories of access control commands. + /// + /// The command flags to use. + /// An array of Redis values representing the categories. + RedisValue[] AccessControlGetCategories(CommandFlags flags = CommandFlags.None); + + /// + /// Asynchronously gets the categories of access control commands. + /// + /// The command flags to use. + /// A task representing the asynchronous operation, with an array of Redis values representing the categories. + Task AccessControlGetCategoriesAsync(CommandFlags flags = CommandFlags.None); + + /// + /// Gets the access control commands for a specified category. + /// + /// The category to get commands for. + /// The command flags to use. + /// An array of Redis values representing the commands. + RedisValue[] AccessControlGetCommands(RedisValue category, CommandFlags flags = CommandFlags.None); + + /// + /// Asynchronously gets the access control commands for a specified category. + /// + /// The category to get commands for. + /// The command flags to use. + /// A task representing the asynchronous operation, with an array of Redis values representing the commands. + Task AccessControlGetCommandsAsync(RedisValue category, CommandFlags flags = CommandFlags.None); + + /// + /// Deletes specified access control users. + /// + /// The usernames of the users to delete. + /// The command flags to use. + /// The number of users deleted. + long AccessControlDeleteUsers(RedisValue[] usernames, CommandFlags flags = CommandFlags.None); + + /// + /// Asynchronously deletes specified access control users. + /// + /// The usernames of the users to delete. + /// The command flags to use. + /// A task representing the asynchronous operation, with the number of users deleted. + Task AccessControlDeleteUsersAsync(RedisValue[] usernames, CommandFlags flags = CommandFlags.None); + + /// + /// Generates a password for access control. + /// + /// The number of bits for the password. + /// The command flags to use. + /// The generated password as a Redis value. + RedisValue AccessControlGeneratePassword(long bits, CommandFlags flags = CommandFlags.None); + + /// + /// Asynchronously generates a password for access control. + /// + /// The number of bits for the password. + /// The command flags to use. + /// A task representing the asynchronous operation, with the generated password as a Redis value. + Task AccessControlGeneratePasswordAsync(long bits, CommandFlags flags = CommandFlags.None); + + /// + /// Loads access control rules. + /// + /// The command flags to use. + void AccessControlLoad(CommandFlags flags = CommandFlags.None); + + /// + /// Asynchronously loads access control rules. + /// + /// The command flags to use. + /// A task representing the asynchronous operation. + Task AccessControlLoadAsync(CommandFlags flags = CommandFlags.None); + + /// + /// Resets the access control log. + /// + /// The command flags to use. + void AccessControlLogReset(CommandFlags flags = CommandFlags.None); + + /// + /// Asynchronously resets the access control log. + /// + /// The command flags to use. + /// A task representing the asynchronous operation. + Task AccessControlLogResetAsync(CommandFlags flags = CommandFlags.None); + + /// + /// Gets the access control log. + /// + /// The number of log entries to retrieve. + /// The command flags to use. + /// An array of key-value pairs representing the log entries. + KeyValuePair[][] AccessControlLog(long count, CommandFlags flags = CommandFlags.None); + + /// + /// Asynchronously gets the access control log. + /// + /// The number of log entries to retrieve. + /// The command flags to use. + /// A task representing the asynchronous operation, with an array of key-value pairs representing the log entries. + Task[][]> AccessControlLogAsync(long count, CommandFlags flags = CommandFlags.None); + + /// + /// Saves access control rules. + /// + /// The command flags to use. + void AccessControlSave(CommandFlags flags = CommandFlags.None); + + /// + /// Asynchronously saves access control rules. + /// + /// The command flags to use. + /// A task representing the asynchronous operation. + Task AccessControlSaveAsync(CommandFlags flags = CommandFlags.None); + + /// + /// Gets the current access control user. + /// + /// The command flags to use. + /// The current access control user as a Redis value. + RedisValue AccessControlWhoAmI(CommandFlags flags = CommandFlags.None); + + /// + /// Asynchronously gets the current access control user. + /// + /// The command flags to use. + /// A task representing the asynchronous operation, with the current access control user as a Redis value. + Task AccessControlWhoAmIAsync(CommandFlags flags = CommandFlags.None); + + /// + /// Sets access control rules for a user. + /// + /// The username to set rules for. + /// The access control rules to set. + /// The command flags to use. + void AccessControlSetUser(RedisValue userName, ACLRules rules, CommandFlags flags = CommandFlags.None); + + /// + /// Asynchronously sets access control rules for a user. + /// + /// The username to set rules for. + /// The access control rules to set. + /// The command flags to use. + /// A task representing the asynchronous operation. + Task AccessControlSetUserAsync(RedisValue userName, ACLRules rules, CommandFlags flags = CommandFlags.None); + /// /// The CLIENT KILL command closes a given client connection identified by ip:port. /// The ip:port should match a line returned by the CLIENT LIST command. diff --git a/src/StackExchange.Redis/Message.cs b/src/StackExchange.Redis/Message.cs index b89a6b946..98a7f7b1a 100644 --- a/src/StackExchange.Redis/Message.cs +++ b/src/StackExchange.Redis/Message.cs @@ -174,6 +174,7 @@ public bool IsAdmin { switch (Command) { + case RedisCommand.ACL: case RedisCommand.BGREWRITEAOF: case RedisCommand.BGSAVE: case RedisCommand.CLIENT: @@ -525,6 +526,26 @@ internal static Message Create(int db, CommandFlags flags, RedisCommand command, return new CommandKeyValuesKeyMessage(db, flags, command, key0, values, key1); } + internal static Message Create(int db, CommandFlags flags, RedisCommand command, RedisValue value, RedisValue[] values) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(values); +#else + if (values == null) throw new ArgumentNullException(nameof(values)); +#endif + return new CommandValueValuesMessage(db, flags, command, value, values); + } + + internal static Message Create(int db, CommandFlags flags, RedisCommand command, RedisValue value0, RedisValue value1, RedisValue[] values) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(values); +#else + if (values == null) throw new ArgumentNullException(nameof(values)); +#endif + return new CommandValueValueValuesMessage(db, flags, command, value0, value1, values); + } + internal static CommandFlags GetPrimaryReplicaFlags(CommandFlags flags) { // for the purposes of the switch, we only care about two bits @@ -535,6 +556,7 @@ internal static bool RequiresDatabase(RedisCommand command) { switch (command) { + case RedisCommand.ACL: case RedisCommand.ASKING: case RedisCommand.AUTH: case RedisCommand.BGREWRITEAOF: @@ -1048,6 +1070,63 @@ protected override void WriteImpl(PhysicalConnection physical) public override int ArgCount => values.Length; } + private sealed class CommandValueValuesMessage : Message + { + private readonly RedisValue value; + private readonly RedisValue[] values; + + public CommandValueValuesMessage(int db, CommandFlags flags, RedisCommand command, RedisValue value, RedisValue[] values) : base(db, flags, command) + { + this.value = value; + for (int i = 0; i < values.Length; i++) + { + values[i].AssertNotNull(); + } + this.values = values; + } + + protected override void WriteImpl(PhysicalConnection physical) + { + physical.WriteHeader(command, values.Length + 1); + physical.WriteBulkString(value); + for (int i = 0; i < values.Length; i++) + { + physical.WriteBulkString(values[i]); + } + } + public override int ArgCount => values.Length + 1; + } + + private sealed class CommandValueValueValuesMessage : Message + { + private readonly RedisValue value0; + private readonly RedisValue value1; + private readonly RedisValue[] values; + + public CommandValueValueValuesMessage(int db, CommandFlags flags, RedisCommand command, RedisValue value0, RedisValue value1, RedisValue[] values) : base(db, flags, command) + { + this.value0 = value0; + this.value1 = value1; + for (int i = 0; i < values.Length; i++) + { + values[i].AssertNotNull(); + } + this.values = values; + } + + protected override void WriteImpl(PhysicalConnection physical) + { + physical.WriteHeader(command, values.Length + 2); + physical.WriteBulkString(value0); + physical.WriteBulkString(value1); + for (int i = 0; i < values.Length; i++) + { + physical.WriteBulkString(values[i]); + } + } + public override int ArgCount => values.Length + 2; + } + private sealed class CommandKeysMessage : Message { private readonly RedisKey[] keys; diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt index a24333c8e..c0887a02c 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt @@ -55,6 +55,98 @@ override StackExchange.Redis.SocketManager.ToString() -> string! override StackExchange.Redis.SortedSetEntry.Equals(object? obj) -> bool override StackExchange.Redis.SortedSetEntry.GetHashCode() -> int override StackExchange.Redis.SortedSetEntry.ToString() -> string! +readonly StackExchange.Redis.ACLCommandRules.CategoriesAllowed -> string![]? +readonly StackExchange.Redis.ACLCommandRules.CategoriesDisallowed -> string![]? +readonly StackExchange.Redis.ACLCommandRules.CommandsAllowed -> string![]? +readonly StackExchange.Redis.ACLCommandRules.CommandsDisallowed -> string![]? +readonly StackExchange.Redis.ACLCommandRules.CommandsRule -> StackExchange.Redis.ACLCommandsRule? +readonly StackExchange.Redis.ACLCommandRules.KeysAllowedPatterns -> string![]? +readonly StackExchange.Redis.ACLCommandRules.KeysAllowedReadForPatterns -> string![]? +readonly StackExchange.Redis.ACLCommandRules.KeysAllowedWriteForPatterns -> string![]? +readonly StackExchange.Redis.ACLCommandRules.KeysRule -> StackExchange.Redis.ACLKeysRule? +readonly StackExchange.Redis.ACLCommandRules.PubSubAllowChannels -> string![]? +readonly StackExchange.Redis.ACLCommandRules.PubSubRule -> StackExchange.Redis.ACLPubSubRule? +readonly StackExchange.Redis.ACLRules.AclCommandRules -> StackExchange.Redis.ACLCommandRules? +readonly StackExchange.Redis.ACLRules.AclSelectorRules -> StackExchange.Redis.ACLSelectorRules![]? +readonly StackExchange.Redis.ACLRules.AclUserRules -> StackExchange.Redis.ACLUserRules? +readonly StackExchange.Redis.ACLSelectorRules.CategoriesAllowed -> string![]? +readonly StackExchange.Redis.ACLSelectorRules.CategoriesDisallowed -> string![]? +readonly StackExchange.Redis.ACLSelectorRules.CommandsAllowed -> string![]? +readonly StackExchange.Redis.ACLSelectorRules.CommandsDisallowed -> string![]? +readonly StackExchange.Redis.ACLSelectorRules.KeysAllowedPatterns -> string![]? +readonly StackExchange.Redis.ACLSelectorRules.KeysAllowedReadForPatterns -> string![]? +readonly StackExchange.Redis.ACLSelectorRules.KeysAllowedWriteForPatterns -> string![]? +readonly StackExchange.Redis.ACLUserRules.ClearSelectors -> bool +readonly StackExchange.Redis.ACLUserRules.HashedPasswordsToRemove -> string![]? +readonly StackExchange.Redis.ACLUserRules.HashedPasswordsToSet -> string![]? +readonly StackExchange.Redis.ACLUserRules.NoPass -> bool +readonly StackExchange.Redis.ACLUserRules.PasswordsToRemove -> string![]? +readonly StackExchange.Redis.ACLUserRules.PasswordsToSet -> string![]? +readonly StackExchange.Redis.ACLUserRules.ResetPass -> bool +readonly StackExchange.Redis.ACLUserRules.ResetUser -> bool +readonly StackExchange.Redis.ACLUserRules.UserState -> StackExchange.Redis.ACLUserState? +StackExchange.Redis.ACLCommandRules +StackExchange.Redis.ACLCommandRules.ACLCommandRules(StackExchange.Redis.ACLCommandsRule? commandsRule, string![]? commandsAllowed, string![]? commandsDisallowed, string![]? categoriesAllowed, string![]? categoriesDisallowed, StackExchange.Redis.ACLKeysRule? keysRule, string![]? keysAllowedPatterns, string![]? keysAllowedReadForPatterns, string![]? keysAllowedWriteForPatterns, StackExchange.Redis.ACLPubSubRule? pubSubRule, string![]? pubSubAllowChannels) -> void +StackExchange.Redis.ACLCommandRulesBuilder +StackExchange.Redis.ACLCommandRulesBuilder.ACLCommandRulesBuilder() -> void +StackExchange.Redis.ACLCommandRulesBuilder.Build() -> StackExchange.Redis.ACLCommandRules! +StackExchange.Redis.ACLCommandRulesBuilder.CategoriesAllowed(params string![]! categories) -> StackExchange.Redis.ACLCommandRulesBuilder! +StackExchange.Redis.ACLCommandRulesBuilder.CategoriesDisallowed(params string![]! categories) -> StackExchange.Redis.ACLCommandRulesBuilder! +StackExchange.Redis.ACLCommandRulesBuilder.CommandsAllowed(params string![]! commands) -> StackExchange.Redis.ACLCommandRulesBuilder! +StackExchange.Redis.ACLCommandRulesBuilder.CommandsDisallowed(params string![]! commands) -> StackExchange.Redis.ACLCommandRulesBuilder! +StackExchange.Redis.ACLCommandRulesBuilder.CommandsRule(StackExchange.Redis.ACLCommandsRule? commandsRule) -> StackExchange.Redis.ACLCommandRulesBuilder! +StackExchange.Redis.ACLCommandRulesBuilder.KeysAllowedPatterns(params string![]! patterns) -> StackExchange.Redis.ACLCommandRulesBuilder! +StackExchange.Redis.ACLCommandRulesBuilder.KeysAllowedReadForPatterns(params string![]! patterns) -> StackExchange.Redis.ACLCommandRulesBuilder! +StackExchange.Redis.ACLCommandRulesBuilder.KeysAllowedWriteForPatterns(params string![]! patterns) -> StackExchange.Redis.ACLCommandRulesBuilder! +StackExchange.Redis.ACLCommandRulesBuilder.KeysRule(StackExchange.Redis.ACLKeysRule? keysRule) -> StackExchange.Redis.ACLCommandRulesBuilder! +StackExchange.Redis.ACLCommandRulesBuilder.PubSubAllowChannels(params string![]! channels) -> StackExchange.Redis.ACLCommandRulesBuilder! +StackExchange.Redis.ACLCommandRulesBuilder.PubSubRule(StackExchange.Redis.ACLPubSubRule? pubSubRule) -> StackExchange.Redis.ACLCommandRulesBuilder! +StackExchange.Redis.ACLCommandsRule +StackExchange.Redis.ACLCommandsRule.ALLCOMMANDS = 0 -> StackExchange.Redis.ACLCommandsRule +StackExchange.Redis.ACLCommandsRule.NOCOMMANDS = 1 -> StackExchange.Redis.ACLCommandsRule +StackExchange.Redis.ACLKeysRule +StackExchange.Redis.ACLKeysRule.ALLKEYS = 0 -> StackExchange.Redis.ACLKeysRule +StackExchange.Redis.ACLKeysRule.RESETKEYS = 1 -> StackExchange.Redis.ACLKeysRule +StackExchange.Redis.ACLPubSubRule +StackExchange.Redis.ACLPubSubRule.ALLCHANNELS = 0 -> StackExchange.Redis.ACLPubSubRule +StackExchange.Redis.ACLPubSubRule.RESETCHANNELS = 1 -> StackExchange.Redis.ACLPubSubRule +StackExchange.Redis.ACLRules +StackExchange.Redis.ACLRules.ACLRules(StackExchange.Redis.ACLUserRules? aclUserRules, StackExchange.Redis.ACLCommandRules? aclCommandRules, StackExchange.Redis.ACLSelectorRules![]? aclSelectorRules) -> void +StackExchange.Redis.ACLRulesBuilder +StackExchange.Redis.ACLRulesBuilder.ACLRulesBuilder() -> void +StackExchange.Redis.ACLRulesBuilder.AppendACLSelectorRules(System.Action! buildAction) -> StackExchange.Redis.ACLRulesBuilder! +StackExchange.Redis.ACLRulesBuilder.Build() -> StackExchange.Redis.ACLRules! +StackExchange.Redis.ACLRulesBuilder.WithACLCommandRules(System.Action! buildAction) -> StackExchange.Redis.ACLRulesBuilder! +StackExchange.Redis.ACLRulesBuilder.WithACLUserRules(System.Action! buildAction) -> StackExchange.Redis.ACLRulesBuilder! +StackExchange.Redis.ACLSelectorRules +StackExchange.Redis.ACLSelectorRules.ACLSelectorRules(string![]? commandsAllowed, string![]? commandsDisallowed, string![]? categoriesAllowed, string![]? categoriesDisallowed, string![]? keysAllowedPatterns, string![]? keysAllowedReadForPatterns, string![]? keysAllowedWriteForPatterns) -> void +StackExchange.Redis.ACLSelectorRulesBuilder +StackExchange.Redis.ACLSelectorRulesBuilder.ACLSelectorRulesBuilder() -> void +StackExchange.Redis.ACLSelectorRulesBuilder.Build() -> StackExchange.Redis.ACLSelectorRules! +StackExchange.Redis.ACLSelectorRulesBuilder.CategoriesAllowed(params string![]! categories) -> StackExchange.Redis.ACLSelectorRulesBuilder! +StackExchange.Redis.ACLSelectorRulesBuilder.CategoriesDisallowed(params string![]! categories) -> StackExchange.Redis.ACLSelectorRulesBuilder! +StackExchange.Redis.ACLSelectorRulesBuilder.CommandsAllowed(params string![]! commands) -> StackExchange.Redis.ACLSelectorRulesBuilder! +StackExchange.Redis.ACLSelectorRulesBuilder.CommandsDisallowed(params string![]! commands) -> StackExchange.Redis.ACLSelectorRulesBuilder! +StackExchange.Redis.ACLSelectorRulesBuilder.KeysAllowedPatterns(params string![]! patterns) -> StackExchange.Redis.ACLSelectorRulesBuilder! +StackExchange.Redis.ACLSelectorRulesBuilder.KeysAllowedReadForPatterns(params string![]! patterns) -> StackExchange.Redis.ACLSelectorRulesBuilder! +StackExchange.Redis.ACLSelectorRulesBuilder.KeysAllowedWriteForPatterns(params string![]! patterns) -> StackExchange.Redis.ACLSelectorRulesBuilder! +StackExchange.Redis.ACLUserRules +StackExchange.Redis.ACLUserRules.ACLUserRules(bool resetUser, bool noPass, bool resetPass, StackExchange.Redis.ACLUserState? userState, string![]? passwordsToSet, string![]? passwordsToRemove, string![]? hashedPasswordsToSet, string![]? hashedPasswordsToRemove, bool clearSelectors) -> void +StackExchange.Redis.ACLUserRulesBuilder +StackExchange.Redis.ACLUserRulesBuilder.ACLUserRulesBuilder() -> void +StackExchange.Redis.ACLUserRulesBuilder.Build() -> StackExchange.Redis.ACLUserRules! +StackExchange.Redis.ACLUserRulesBuilder.ClearSelectors(bool clearSelectors) -> StackExchange.Redis.ACLUserRulesBuilder! +StackExchange.Redis.ACLUserRulesBuilder.HashedPasswordsToRemove(params string![]! hashedPasswords) -> StackExchange.Redis.ACLUserRulesBuilder! +StackExchange.Redis.ACLUserRulesBuilder.HashedPasswordsToSet(params string![]! hashedPasswords) -> StackExchange.Redis.ACLUserRulesBuilder! +StackExchange.Redis.ACLUserRulesBuilder.NoPass(bool noPass) -> StackExchange.Redis.ACLUserRulesBuilder! +StackExchange.Redis.ACLUserRulesBuilder.PasswordsToRemove(params string![]! passwords) -> StackExchange.Redis.ACLUserRulesBuilder! +StackExchange.Redis.ACLUserRulesBuilder.PasswordsToSet(params string![]! passwords) -> StackExchange.Redis.ACLUserRulesBuilder! +StackExchange.Redis.ACLUserRulesBuilder.ResetPass(bool resetPass) -> StackExchange.Redis.ACLUserRulesBuilder! +StackExchange.Redis.ACLUserRulesBuilder.ResetUser(bool resetUser) -> StackExchange.Redis.ACLUserRulesBuilder! +StackExchange.Redis.ACLUserRulesBuilder.UserState(StackExchange.Redis.ACLUserState? userState) -> StackExchange.Redis.ACLUserRulesBuilder! +StackExchange.Redis.ACLUserState +StackExchange.Redis.ACLUserState.OFF = 1 -> StackExchange.Redis.ACLUserState +StackExchange.Redis.ACLUserState.ON = 0 -> StackExchange.Redis.ACLUserState StackExchange.Redis.Aggregate StackExchange.Redis.Aggregate.Max = 2 -> StackExchange.Redis.Aggregate StackExchange.Redis.Aggregate.Min = 1 -> StackExchange.Redis.Aggregate @@ -1039,6 +1131,26 @@ StackExchange.Redis.IServer.AllowReplicaWrites.get -> bool StackExchange.Redis.IServer.AllowReplicaWrites.set -> void StackExchange.Redis.IServer.AllowSlaveWrites.get -> bool StackExchange.Redis.IServer.AllowSlaveWrites.set -> void +StackExchange.Redis.IServer.AccessControlGetCategories(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.RedisValue[]! +StackExchange.Redis.IServer.AccessControlGetCategoriesAsync(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +StackExchange.Redis.IServer.AccessControlGetCommands(StackExchange.Redis.RedisValue category, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.RedisValue[]! +StackExchange.Redis.IServer.AccessControlGetCommandsAsync(StackExchange.Redis.RedisValue category, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +StackExchange.Redis.IServer.AccessControlDeleteUsers(StackExchange.Redis.RedisValue[]! usernames, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> long +StackExchange.Redis.IServer.AccessControlDeleteUsersAsync(StackExchange.Redis.RedisValue[]! usernames, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +StackExchange.Redis.IServer.AccessControlGeneratePassword(long bits, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.RedisValue +StackExchange.Redis.IServer.AccessControlGeneratePasswordAsync(long bits, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +StackExchange.Redis.IServer.AccessControlLoad(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> void +StackExchange.Redis.IServer.AccessControlLoadAsync(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +StackExchange.Redis.IServer.AccessControlLogReset(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> void +StackExchange.Redis.IServer.AccessControlLogResetAsync(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +StackExchange.Redis.IServer.AccessControlLog(long count, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Collections.Generic.KeyValuePair[]![]! +StackExchange.Redis.IServer.AccessControlLogAsync(long count, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task[]![]!>! +StackExchange.Redis.IServer.AccessControlSave(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> void +StackExchange.Redis.IServer.AccessControlSaveAsync(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +StackExchange.Redis.IServer.AccessControlWhoAmI(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.RedisValue +StackExchange.Redis.IServer.AccessControlWhoAmIAsync(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +StackExchange.Redis.IServer.AccessControlSetUser(StackExchange.Redis.RedisValue userName, StackExchange.Redis.ACLRules! rules, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> void +StackExchange.Redis.IServer.AccessControlSetUserAsync(StackExchange.Redis.RedisValue userName, StackExchange.Redis.ACLRules! rules, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! StackExchange.Redis.IServer.ClientKill(long? id = null, StackExchange.Redis.ClientType? clientType = null, System.Net.EndPoint? endpoint = null, bool skipMe = true, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> long StackExchange.Redis.IServer.ClientKill(System.Net.EndPoint! endpoint, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> void StackExchange.Redis.IServer.ClientKill(StackExchange.Redis.ClientKillFilter! filter, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> long diff --git a/src/StackExchange.Redis/RedisLiterals.cs b/src/StackExchange.Redis/RedisLiterals.cs index 549691fd2..46fbeec4a 100644 --- a/src/StackExchange.Redis/RedisLiterals.cs +++ b/src/StackExchange.Redis/RedisLiterals.cs @@ -66,12 +66,15 @@ public static readonly RedisValue BYLEX = "BYLEX", BYSCORE = "BYSCORE", BYTE = "BYTE", + CAT = "CAT", CH = "CH", CHANNELS = "CHANNELS", + CLEARSELECTORS = "CLEARSELECTORS", COPY = "COPY", COUNT = "COUNT", DB = "DB", @default = "default", + DELUSER = "DELUSER", DESC = "DESC", DOCTOR = "DOCTOR", ENCODING = "ENCODING", @@ -82,6 +85,7 @@ public static readonly RedisValue FILTERBY = "FILTERBY", FLUSH = "FLUSH", FREQ = "FREQ", + GENPASS = "GENPASS", GET = "GET", GETKEYS = "GETKEYS", GETNAME = "GETNAME", @@ -101,6 +105,7 @@ public static readonly RedisValue LIMIT = "LIMIT", LIST = "LIST", LOAD = "LOAD", + LOG = "LOG", LT = "LT", MATCH = "MATCH", MALLOC_STATS = "MALLOC-STATS", @@ -111,6 +116,7 @@ public static readonly RedisValue MINMATCHLEN = "MINMATCHLEN", MODULE = "MODULE", NODES = "NODES", + NOPASS = "NOPASS", NOSAVE = "NOSAVE", NOT = "NOT", NOVALUES = "NOVALUES", @@ -130,6 +136,7 @@ public static readonly RedisValue REFCOUNT = "REFCOUNT", REPLACE = "REPLACE", RESET = "RESET", + RESETPASS = "RESETPASS", RESETSTAT = "RESETSTAT", REV = "REV", REWRITE = "REWRITE", @@ -139,12 +146,14 @@ public static readonly RedisValue SET = "SET", SETINFO = "SETINFO", SETNAME = "SETNAME", + SETUSER = "SETUSER", SKIPME = "SKIPME", STATS = "STATS", STORE = "STORE", TYPE = "TYPE", USERNAME = "USERNAME", WEIGHTS = "WEIGHTS", + WHOAMI = "WHOAMI", WITHMATCHLEN = "WITHMATCHLEN", WITHSCORES = "WITHSCORES", WITHVALUES = "WITHVALUES", diff --git a/src/StackExchange.Redis/RedisServer.cs b/src/StackExchange.Redis/RedisServer.cs index 8810e1e2b..201228938 100644 --- a/src/StackExchange.Redis/RedisServer.cs +++ b/src/StackExchange.Redis/RedisServer.cs @@ -51,6 +51,126 @@ public bool AllowReplicaWrites public Version Version => server.Version; + public RedisValue[] AccessControlGetCategories(CommandFlags flags = CommandFlags.None) + { + var msg = Message.Create(-1, flags, RedisCommand.ACL, RedisLiterals.CAT); + return ExecuteSync(msg, ResultProcessor.RedisValueArray, defaultValue: Array.Empty()); + } + + public Task AccessControlGetCategoriesAsync(CommandFlags flags = CommandFlags.None) + { + var msg = Message.Create(-1, flags, RedisCommand.ACL, RedisLiterals.CAT); + return ExecuteAsync(msg, ResultProcessor.RedisValueArray, defaultValue: Array.Empty()); + } + + public RedisValue[] AccessControlGetCommands(RedisValue category, CommandFlags flags = CommandFlags.None) + { + var msg = Message.Create(-1, flags, RedisCommand.ACL, RedisLiterals.CAT, category); + return ExecuteSync(msg, ResultProcessor.RedisValueArray, defaultValue: Array.Empty()); + } + + public Task AccessControlGetCommandsAsync(RedisValue category, CommandFlags flags = CommandFlags.None) + { + var msg = Message.Create(-1, flags, RedisCommand.ACL, RedisLiterals.CAT, category); + return ExecuteAsync(msg, ResultProcessor.RedisValueArray, defaultValue: Array.Empty()); + } + + public long AccessControlDeleteUsers(RedisValue[] usernames, CommandFlags flags = CommandFlags.None) + { + var msg = Message.Create(-1, flags, RedisCommand.ACL, RedisLiterals.DELUSER, usernames); + return ExecuteSync(msg, ResultProcessor.Int64); + } + + public Task AccessControlDeleteUsersAsync(RedisValue[] usernames, CommandFlags flags = CommandFlags.None) + { + var msg = Message.Create(-1, flags, RedisCommand.ACL, RedisLiterals.DELUSER, usernames); + return ExecuteAsync(msg, ResultProcessor.Int64); + } + + public RedisValue AccessControlGeneratePassword(long bits, CommandFlags flags = CommandFlags.None) + { + var msg = Message.Create(-1, flags, RedisCommand.ACL, RedisLiterals.GENPASS, bits); + return ExecuteSync(msg, ResultProcessor.RedisValue); + } + + public Task AccessControlGeneratePasswordAsync(long bits, CommandFlags flags = CommandFlags.None) + { + var msg = Message.Create(-1, flags, RedisCommand.ACL, RedisLiterals.GENPASS, bits); + return ExecuteAsync(msg, ResultProcessor.RedisValue); + } + + public void AccessControlLoad(CommandFlags flags = CommandFlags.None) + { + var msg = Message.Create(-1, flags, RedisCommand.ACL, RedisLiterals.LOAD); + ExecuteSync(msg, ResultProcessor.DemandOK); + } + + public Task AccessControlLoadAsync(CommandFlags flags = CommandFlags.None) + { + var msg = Message.Create(-1, flags, RedisCommand.ACL, RedisLiterals.LOAD); + return ExecuteAsync(msg, ResultProcessor.DemandOK); + } + + public void AccessControlLogReset(CommandFlags flags = CommandFlags.None) + { + var msg = Message.Create(-1, flags, RedisCommand.ACL, RedisLiterals.LOG, RedisLiterals.RESET); + ExecuteSync(msg, ResultProcessor.DemandOK); + } + + public Task AccessControlLogResetAsync(CommandFlags flags = CommandFlags.None) + { + var msg = Message.Create(-1, flags, RedisCommand.ACL, RedisLiterals.LOG, RedisLiterals.RESET); + return ExecuteAsync(msg, ResultProcessor.DemandOK); + } + + public KeyValuePair[][] AccessControlLog(long count, CommandFlags flags = CommandFlags.None) + { + var msg = Message.Create(-1, flags, RedisCommand.ACL, RedisLiterals.LOG, count); + return ExecuteSync(msg, ResultProcessor.ArrayOfKeyValueArray, defaultValue: Array.Empty[]>()); + } + + public Task[][]> AccessControlLogAsync(long count, CommandFlags flags = CommandFlags.None) + { + var msg = Message.Create(-1, flags, RedisCommand.ACL, RedisLiterals.LOG, count); + return ExecuteAsync(msg, ResultProcessor.ArrayOfKeyValueArray, defaultValue: Array.Empty[]>()); + } + + public void AccessControlSave(CommandFlags flags = CommandFlags.None) + { + var msg = Message.Create(-1, flags, RedisCommand.ACL, RedisLiterals.SAVE); + ExecuteSync(msg, ResultProcessor.DemandOK); + } + + public Task AccessControlSaveAsync(CommandFlags flags = CommandFlags.None) + { + var msg = Message.Create(-1, flags, RedisCommand.ACL, RedisLiterals.SAVE); + return ExecuteAsync(msg, ResultProcessor.DemandOK); + } + + public RedisValue AccessControlWhoAmI(CommandFlags flags = CommandFlags.None) + { + var msg = Message.Create(-1, flags, RedisCommand.ACL, RedisLiterals.WHOAMI); + return ExecuteSync(msg, ResultProcessor.RedisValue); + } + + public Task AccessControlWhoAmIAsync(CommandFlags flags = CommandFlags.None) + { + var msg = Message.Create(-1, flags, RedisCommand.ACL, RedisLiterals.WHOAMI); + return ExecuteAsync(msg, ResultProcessor.RedisValue); + } + + public void AccessControlSetUser(RedisValue userName, ACLRules rules, CommandFlags flags = CommandFlags.None) + { + var msg = Message.Create(-1, flags, RedisCommand.ACL, RedisLiterals.SETUSER, userName, rules.ToRedisValues()); + ExecuteSync(msg, ResultProcessor.DemandOK); + } + + public Task AccessControlSetUserAsync(RedisValue userName, ACLRules rules, CommandFlags flags = CommandFlags.None) + { + var msg = Message.Create(-1, flags, RedisCommand.ACL, RedisLiterals.SETUSER, userName, rules.ToRedisValues()); + return ExecuteAsync(msg, ResultProcessor.DemandOK); + } + public void ClientKill(EndPoint endpoint, CommandFlags flags = CommandFlags.None) { var msg = Message.Create(-1, flags, RedisCommand.CLIENT, RedisLiterals.KILL, (RedisValue)Format.ToString(endpoint)); diff --git a/src/StackExchange.Redis/ResultProcessor.cs b/src/StackExchange.Redis/ResultProcessor.cs index 648387b87..c8a7940b4 100644 --- a/src/StackExchange.Redis/ResultProcessor.cs +++ b/src/StackExchange.Redis/ResultProcessor.cs @@ -187,6 +187,9 @@ public static readonly TimeSpanProcessor public static readonly HashEntryArrayProcessor HashEntryArray = new HashEntryArrayProcessor(); + public static readonly KeyValuePairProcessor KeyValuePair = new KeyValuePairProcessor(); + public static readonly ArrayOfKeyValueArrayProcessor ArrayOfKeyValueArray = new ArrayOfKeyValueArrayProcessor(); + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "Conditionally run on instance")] public void ConnectionFail(Message message, ConnectionFailureType fail, Exception? innerException, string? annotation, ConnectionMultiplexer? muxer) { @@ -2887,6 +2890,41 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes return false; } } + + internal sealed class ArrayOfKeyValueArrayProcessor : ResultProcessor[][]> + { + protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + { + switch (result.Resp2TypeArray) + { + case ResultType.Array: + var arrayOfArrays = result.GetItems(); + + var returnArray = result.ToArray[], KeyValuePairProcessor>( + (in RawResult rawInnerArray, in KeyValuePairProcessor proc) => + { + if (proc.TryParse(rawInnerArray, out KeyValuePair[]? kvpArray)) + { + return kvpArray!; + } + else + { + throw new ArgumentOutOfRangeException(nameof(rawInnerArray), $"Error processing {message.CommandAndKey}, could not decode array '{rawInnerArray}'"); + } + }, + KeyValuePair)!; + + SetResult(message, returnArray); + return true; + } + return false; + } + } + internal sealed class KeyValuePairProcessor : ValuePairInterleavedProcessorBase> + { + protected override KeyValuePair Parse(in RawResult first, in RawResult second, object? state) => + new KeyValuePair(first.GetString()!, second.AsRedisValue()); + } } internal abstract class ResultProcessor : ResultProcessor diff --git a/tests/StackExchange.Redis.Tests/ACLIntegrationTests.cs b/tests/StackExchange.Redis.Tests/ACLIntegrationTests.cs new file mode 100644 index 000000000..8ef456386 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ACLIntegrationTests.cs @@ -0,0 +1,135 @@ +using System.Linq; +using System.Threading; +using NSubstitute; +using Xunit; +using Xunit.Abstractions; + +namespace StackExchange.Redis.Tests; + +[RunPerProtocol] +[Collection(SharedConnectionFixture.Key)] +public class ACLIntegrationTests : TestBase +{ + private readonly IConnectionMultiplexer _conn; + private readonly IServer _redisServer; + + public ACLIntegrationTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base(output, fixture) + { + _conn = Create(); + _redisServer = GetAnyPrimary(_conn); + } + + [Fact] + public void AccessControlGetCategories_ShouldReturnCategories() + { + // Act + var categories = _redisServer.AccessControlGetCategories(); + + // Assert + Assert.NotNull(categories); + Assert.Contains("write", categories); + Assert.Contains("set", categories); + Assert.Contains("list", categories); + } + + [Fact] + public void AccessControlGetCommands_ShouldReturnCommands() + { + // Act + var commands = _redisServer.AccessControlGetCommands("set"); + + // Assert + Assert.NotNull(commands); + Assert.Contains("sort", commands); + Assert.Contains("spop", commands); + } + + [Fact] + public void AccessControlDeleteUsers_ShouldReturnCorrectCount() + { + // Arrange + _redisServer.AccessControlSetUser(new RedisValue("user1"), new ACLRulesBuilder().Build()); + + // Act + var count = _redisServer.AccessControlDeleteUsers(new RedisValue[] { "user1", "user2" }); + + // Assert + Assert.Equal(1, count); + } + + [Fact] + public void AccessControlGeneratePassword_ShouldReturnGeneratedPassword() + { + // Act + var password = _redisServer.AccessControlGeneratePassword(256); + + // Assert + Assert.True(password.HasValue); + Assert.True(password.ToString().Length > 0); // Ensure a password is generated + } + + [Fact] + public void AccessControlLogReset_ShouldExecuteSuccessfully() + { + // Act + _redisServer.AccessControlLogReset(); + + // Assert + // The action is successful if no exceptions are thrown + } + + [Fact] + public void AccessControlLog_ShouldReturnLogs() + { + // Arrange + _redisServer.AccessControlSetUser( + "user1", + new ACLRulesBuilder() + .WithACLUserRules(rules => rules.PasswordsToSet(new[] { "pass1" }) + .UserState(ACLUserState.ON)) + .Build()); + + Assert.Throws(() => _conn.GetDatabase().Execute("AUTH", "user1", "pass2")); + + // Act + var logs = _redisServer.AccessControlLog(10); + + // Assert + Assert.NotNull(logs); + Assert.NotEmpty(logs); + Assert.Contains(logs[0], x => x.Key == "reason"); + } + + [Fact] + public void AccessControlWhoAmI_ShouldReturnCurrentUser() + { + // // Arrange + // var conn = Create(require: RedisFeatures.v7_0_0_rc1); + // var redisServer = (RedisServer)GetAnyPrimary(conn); + + // redisServer.AccessControlSetUser( + // "user1", + // new ACLRulesBuilder() + // .WithACLUserRules(rules => rules.PasswordsToSet(new[] { "pass1" }) + // .UserState(UserState.ON)) + // .Build()); + + // Act + var user = _redisServer.AccessControlWhoAmI(); + + // Assert + Assert.True(user.HasValue); + Assert.True(user.ToString().Length > 0); // Ensure there's a valid user returned + } + + [Fact] + public void AccessControlSetUser_ShouldSetUserWithGivenRules() + { + // Act + _redisServer.AccessControlSetUser(new RedisValue("testuser"), new ACLRules(null, null, null)); + + // Assert + // In this case, we're asserting that no exceptions are thrown and the user is successfully set. + // To validate this, you might want to verify if the user exists in your Redis instance or use a similar check. + } +} diff --git a/tests/StackExchange.Redis.Tests/ACLTests.cs b/tests/StackExchange.Redis.Tests/ACLTests.cs new file mode 100644 index 000000000..68cc0663f --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ACLTests.cs @@ -0,0 +1,407 @@ +using System.Collections.Generic; +using Xunit; +using Xunit.Abstractions; + +namespace StackExchange.Redis.Tests; + +[Collection(SharedConnectionFixture.Key)] +public class ACLTests : TestBase +{ + public ACLTests(ITestOutputHelper output) : base(output) { } + + protected override string GetConfiguration() => TestConfig.Current.PrimaryServerAndPort; + + [Fact] + public void ToRedisValues_ShouldReturnCorrectValues_WhenAllFieldsAreSet() + { + // Arrange + var aclUserRules = new ACLUserRules( + resetUser: true, + noPass: true, + resetPass: false, + userState: ACLUserState.ON, + passwordsToSet: new[] { "password1", "password2" }, + passwordsToRemove: new[] { "password3" }, + hashedPasswordsToSet: new[] { "hashed1", "hashed2" }, + hashedPasswordsToRemove: new[] { "hashed3" }, + clearSelectors: true); + + var aclCommandRules = new ACLCommandRules( + commandsRule: ACLCommandsRule.NOCOMMANDS, + commandsAllowed: new[] { "GET", "SET" }, + commandsDisallowed: new[] { "DEL" }, + categoriesAllowed: new[] { "string", "list" }, + categoriesDisallowed: new[] { "set", "hash" }, + keysRule: ACLKeysRule.ALLKEYS, + keysAllowedPatterns: new[] { "user:*", "session:*" }, + keysAllowedReadForPatterns: new[] { "user:*" }, + keysAllowedWriteForPatterns: new[] { "session:*" }, + pubSubRule: ACLPubSubRule.ALLCHANNELS, + pubSubAllowChannels: new[] { "channel1", "channel2" }); + + var aclSelectorRules = new[] + { + new ACLSelectorRules( + commandsAllowed: new[] { "GET", }, + commandsDisallowed: new[] { "SET" }, + categoriesAllowed: new[] { "string" }, + categoriesDisallowed: new[] { "list" }, + keysAllowedPatterns: new[] { "user:*" }, + keysAllowedReadForPatterns: new[] { "session:*" }, + keysAllowedWriteForPatterns: new[] { "user:*" }), + }; + + var aclRules = new ACLRules(aclUserRules, aclCommandRules, aclSelectorRules); + + // Act + var redisValues = aclRules.ToRedisValues(); + + // Assert + var expectedValues = new List + { + RedisLiterals.RESET, + RedisLiterals.NOPASS, + "ON", + ">password1", + ">password2", + " + { + "ALLCOMMANDS", + "ALLKEYS", + "ALLCHANNELS", + }; + + Assert.Equal(expectedValues, redisValues); + } + + [Fact] + public void ToRedisValues_NoCommandsNoKeysResetChannels() + { + // Arrange + var aclUserRules = new ACLUserRules( + resetUser: false, + noPass: false, + resetPass: false, + userState: null, + passwordsToSet: null, + passwordsToRemove: null, + hashedPasswordsToSet: null, + hashedPasswordsToRemove: null, + clearSelectors: false); + + var aclCommandRules = new ACLCommandRules( + commandsRule: ACLCommandsRule.NOCOMMANDS, + commandsAllowed: null, + commandsDisallowed: null, + categoriesAllowed: null, + categoriesDisallowed: null, + keysRule: ACLKeysRule.RESETKEYS, + keysAllowedPatterns: null, + keysAllowedReadForPatterns: null, + keysAllowedWriteForPatterns: null, + pubSubRule: ACLPubSubRule.RESETCHANNELS, + pubSubAllowChannels: null); + + // Act + var aclRules = new ACLRules(aclUserRules, aclCommandRules, null); + var redisValues = aclRules.ToRedisValues(); + + // Assert + var expectedValues = new List + { + "NOCOMMANDS", + "RESETKEYS", + "RESETCHANNELS", + }; + + Assert.Equal(expectedValues, redisValues); + } + + [Fact] + public void Build_ShouldCreateACLRulesWithUserRules_WhenUserRulesAreSet() + { + // Arrange + var builder = new ACLRulesBuilder(); + + builder.WithACLUserRules(userBuilder => userBuilder + .ResetUser(true) + .NoPass(true) + .UserState(ACLUserState.ON) + .PasswordsToSet("password123") + .ClearSelectors(true)); + + // Act + var aclRules = builder.Build(); + + // Assert + Assert.NotNull(aclRules.AclUserRules); + Assert.True(aclRules.AclUserRules?.ResetUser); + Assert.True(aclRules.AclUserRules?.NoPass); + Assert.Equal(ACLUserState.ON, aclRules.AclUserRules?.UserState); + Assert.Contains(">password123", aclRules.ToRedisValues()); + Assert.Contains(RedisLiterals.CLEARSELECTORS, aclRules.ToRedisValues()); + } + + [Fact] + public void Build_ShouldCreateACLRulesWithCommandRules_WhenCommandRulesAreSet() + { + // Arrange + var builder = new ACLRulesBuilder(); + + builder.WithACLCommandRules(commandBuilder => commandBuilder + .CommandsRule(ACLCommandsRule.ALLCOMMANDS) + .CommandsAllowed("GET", "SET") + .CommandsDisallowed("DEL") + .CategoriesAllowed("string") + .KeysRule(ACLKeysRule.ALLKEYS) + .KeysAllowedPatterns("user:*", "session:*")); + + // Act + var aclRules = builder.Build(); + + // Assert + Assert.NotNull(aclRules.AclCommandRules); + Assert.Equal(ACLCommandsRule.ALLCOMMANDS, aclRules.AclCommandRules?.CommandsRule); + Assert.Contains("+GET", aclRules.ToRedisValues()); + Assert.Contains("-DEL", aclRules.ToRedisValues()); + Assert.Contains("+@string", aclRules.ToRedisValues()); + Assert.Contains("~user:*", aclRules.ToRedisValues()); + } + + [Fact] + public void Build_ShouldCreateACLRulesWithSelectorRules_WhenSelectorRulesAreSet() + { + // Arrange + var builder = new ACLRulesBuilder(); + + builder.AppendACLSelectorRules(selectorBuilder => selectorBuilder + .CommandsAllowed("GET") + .CommandsDisallowed("SET") + .CategoriesAllowed("list") + .KeysAllowedPatterns("session:*")); + + // Act + var aclRules = builder.Build(); + + // Assert + Assert.NotNull(aclRules.AclSelectorRules); + Assert.Single(aclRules.AclSelectorRules); + Assert.Contains("+GET", aclRules.ToRedisValues()); + Assert.Contains("-SET", aclRules.ToRedisValues()); + Assert.Contains("+@list", aclRules.ToRedisValues()); + Assert.Contains("~session:*", aclRules.ToRedisValues()); + } + + [Fact] + public void Build_ShouldCreateACLRulesWithAllComponents_WhenAllRulesAreSet() + { + // Arrange + var builder = new ACLRulesBuilder(); + + builder.WithACLUserRules(userBuilder => userBuilder + .ResetUser(true) + .NoPass(true) + .UserState(ACLUserState.OFF) + .PasswordsToSet("newpassword") + .ClearSelectors(true)); + + builder.WithACLCommandRules(commandBuilder => commandBuilder + .CommandsRule(ACLCommandsRule.NOCOMMANDS) + .CommandsAllowed("GET", "SET") + .CategoriesAllowed("list") + .KeysRule(ACLKeysRule.RESETKEYS) + .KeysAllowedPatterns("user:*")); + + builder.AppendACLSelectorRules(selectorBuilder => selectorBuilder + .CommandsDisallowed("DEL") + .CategoriesDisallowed("hash") + .KeysAllowedPatterns("session:*")); + + // Act + var aclRules = builder.Build(); + + // Assert + // Verify ACLUserRules + Assert.True(aclRules.AclUserRules?.ResetUser); + Assert.True(aclRules.AclUserRules?.NoPass); + Assert.Equal(ACLUserState.OFF, aclRules.AclUserRules?.UserState); + Assert.Contains(">newpassword", aclRules.ToRedisValues()); + Assert.Contains(RedisLiterals.CLEARSELECTORS, aclRules.ToRedisValues()); + + // Verify ACLCommandRules + Assert.Equal(ACLCommandsRule.NOCOMMANDS, aclRules.AclCommandRules?.CommandsRule); + Assert.Contains("+GET", aclRules.ToRedisValues()); + Assert.Contains("+SET", aclRules.ToRedisValues()); + Assert.Contains("+@list", aclRules.ToRedisValues()); + Assert.Contains("~user:*", aclRules.ToRedisValues()); + + // Verify ACLSelectorRules + Assert.NotNull(aclRules.AclSelectorRules); + Assert.Single(aclRules.AclSelectorRules); + Assert.Contains("-DEL", aclRules.ToRedisValues()); + Assert.Contains("-@hash", aclRules.ToRedisValues()); + Assert.Contains("~session:*", aclRules.ToRedisValues()); + } + + [Fact] + public void Build_ShouldHandleEmptyInput_WhenNoRulesAreSet() + { + // Arrange + var builder = new ACLRulesBuilder(); + + // Act + var aclRules = builder.Build(); + + // Assert + Assert.Null(aclRules.AclUserRules); + Assert.Null(aclRules.AclCommandRules); + Assert.Null(aclRules.AclSelectorRules); + Assert.Empty(aclRules.ToRedisValues()); + } + + [Fact] + public void Build_ShouldCreateACLRulesWithMultipleSelectorRules_WhenMultipleAreAppended() + { + // Arrange + var builder = new ACLRulesBuilder(); + + builder.AppendACLSelectorRules(selectorBuilder => selectorBuilder + .CommandsAllowed("GET") + .CategoriesAllowed("string")); + + builder.AppendACLSelectorRules(selectorBuilder => selectorBuilder + .CommandsDisallowed("SET") + .CategoriesDisallowed("list")); + + // Act + var aclRules = builder.Build(); + + // Assert + Assert.NotNull(aclRules.AclSelectorRules); + Assert.Equal(2, aclRules.AclSelectorRules.Length); + + Assert.Contains("+GET", aclRules.ToRedisValues()); + Assert.Contains("+@string", aclRules.ToRedisValues()); + + Assert.Contains("-SET", aclRules.ToRedisValues()); + Assert.Contains("-@list", aclRules.ToRedisValues()); + } +}