Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/core/overloaded.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Copyright 2022, DragonflyDB authors. All rights reserved.
// See LICENSE for licensing terms.
//
//

template <class... Ts> struct Overloaded : Ts... { using Ts::operator()...; };

template <class... Ts> Overloaded(Ts...) -> Overloaded<Ts...>;
6 changes: 3 additions & 3 deletions src/server/acl/acl_commands_def.h
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,9 @@ inline const absl::flat_hash_map<std::string_view, uint32_t> CATEGORY_INDEX_TABL
{"SCRIPTING", SCRIPTING},
{"FT_SEARCH", FT_SEARCH},
{"THROTTLE", THROTTLE},
{"JSON", JSON}

};
{"JSON", JSON},
{"ALL", ALL},
{"NONE", NONE}};

// bit 0 at index 0
// bit 1 at index 1
Expand Down
116 changes: 111 additions & 5 deletions src/server/acl/acl_family.cc
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,30 @@

#include "server/acl/acl_family.h"

#include <optional>
#include <variant>

#include "absl/strings/ascii.h"
#include "absl/strings/match.h"
#include "absl/strings/str_cat.h"
#include "core/overloaded.h"
#include "facade/facade_types.h"
#include "server/acl/acl_commands_def.h"
#include "server/command_registry.h"
#include "server/conn_context.h"
#include "server/server_state.h"

namespace dfly::acl {

constexpr uint32_t kList = acl::ADMIN | acl::SLOW | acl::DANGEROUS;

static std::string AclToString(uint32_t acl_category) {
std::string tmp;

if (acl_category == acl::ALL) {
return "+@all";
return "+@ALL";
}

if (acl_category == acl::NONE) {
return "+@none";
return "+@NONE";
}

const std::string prefix = "+@";
Expand All @@ -32,7 +37,8 @@ static std::string AclToString(uint32_t acl_category) {
absl::StrAppend(&tmp, prefix, REVERSE_CATEGORY_INDEX_TABLE[step], postfix);
}
}
tmp.erase(tmp.size());

tmp.pop_back();

return tmp;
}
Expand Down Expand Up @@ -61,10 +67,108 @@ void AclFamily::List(CmdArgList args, ConnectionContext* cntx) {
}
}

namespace {

std::optional<std::string> MaybeParsePassword(std::string_view command) {
if (command[0] != '>') {
return {};
}

return {std::string(command.substr(1))};
}

std::optional<bool> MaybeParseStatus(std::string_view command) {
if (command == "ON") {
return true;
}
if (command == "OFF") {
return false;
}
return {};
}

using OptCat = std::optional<uint32_t>;

// bool == true if +
// bool == false if -
std::pair<OptCat, bool> MaybeParseAclCategory(std::string_view command) {
if (absl::StartsWith(command, "+@")) {
auto res = CATEGORY_INDEX_TABLE.find(command.substr(2));
if (res == CATEGORY_INDEX_TABLE.end()) {
return {};
}
return {res->second, true};
}

if (absl::StartsWith(command, "-@")) {
auto res = CATEGORY_INDEX_TABLE.find(command.substr(2));
if (res == CATEGORY_INDEX_TABLE.end()) {
return {};
}
return {res->second, false};
}

return {};
}

using facade::ErrorReply;

std::variant<User::UpdateRequest, ErrorReply> ParseAclSetUser(CmdArgList args) {
User::UpdateRequest req;

for (auto arg : args) {
if (auto pass = MaybeParsePassword(facade::ToSV(arg)); pass) {
if (req.password) {
return ErrorReply("Only one password is allowed");
}
req.password = std::move(pass);
continue;
}

ToUpper(&arg);
const auto command = facade::ToSV(arg);

if (auto status = MaybeParseStatus(command); status) {
if (req.is_active) {
return ErrorReply("Multiple ON/OFF are not allowed");
}
req.is_active = *status;
continue;
}

auto [cat, add] = MaybeParseAclCategory(command);
if (!cat) {
return ErrorReply(absl::StrCat("Unrecognized paramter", command));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return ErrorReply(absl::StrCat("Unrecognized paramter", command));
return ErrorReply(absl::StrCat("Unrecognized parameter", command));

}

auto* acl_field = add ? &req.plus_acl_categories : &req.minus_acl_categories;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit silly, but do we want to check no category appears both as a minus and as a plus?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is from the previous PR. What do you mean no caregory? You mean NONE ?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

like what happens if I do ACL SETUSER +@JSON -@JSON

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So I thought about this before, and for this specific case there won't be an issue because the ADD set is applied Before the REMOVE set and I thought that this redundancy is fine. However, now I am thinking about it, for the reverse case this is not true, that is -@JSON +@JSON won't do what is expected and therefore I think we should only allow a group once per SET USER command (and probably this will require some special handling for some categories, example is -@JSON +@ALL).

Another solution is to apply them one by one in order which is slightly slower (since I can apply them all at once) but I don't expect it will affect the performance and IMO I think this is a better solution since it covers the ordering and we can allow +@JSON -@JSON and vice versa.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will address this in a separate PR since this is about the AUTH command

Copy link
Contributor

@dranikpg dranikpg Aug 24, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wait... What operations are permitted? If the string is SSO, then any operations on the map (rehashing, shrinking, etc) will invalidate the string_view 🤨

using RegistryType = absl::flat_hash_map<std::string, User>;

PS: I wanted to put this comment below the std::optional<std::string_view> authed_username; discussion 😬

*acl_field = acl_field->value_or(0) | *cat;
}

return req;
}

} // namespace

void AclFamily::SetUser(CmdArgList args, ConnectionContext* cntx) {
std::string_view username = facade::ToSV(args[0]);
auto req = ParseAclSetUser(args.subspan(1));
auto error_case = [cntx](ErrorReply&& error) { (*cntx)->SendError(error); };
auto update_case = [username, cntx](User::UpdateRequest&& req) {
ServerState::tlocal()->user_registry->MaybeAddAndUpdate(username, std::move(req));
(*cntx)->SendOk();
};

std::visit(Overloaded{error_case, update_case}, std::move(req));
}

using CI = dfly::CommandId;

#define HFUNC(x) SetHandler(&AclFamily::x)

constexpr uint32_t kList = acl::ADMIN | acl::SLOW | acl::DANGEROUS;
constexpr uint32_t kSetUser = acl::ADMIN | acl::SLOW | acl::DANGEROUS;

// We can't implement the ACL commands and its respective subcommands LIST, CAT, etc
// the usual way, (that is, one command called ACL which then dispatches to the subcommand
// based on the secocond argument) because each of the subcommands has different ACL
Expand All @@ -76,6 +180,8 @@ void AclFamily::Register(dfly::CommandRegistry* registry) {
*registry << CI{"ACL", CO::NOSCRIPT | CO::LOADING, 0, 0, 0, 0, acl::kList}.HFUNC(Acl);
*registry << CI{"ACL LIST", CO::ADMIN | CO::NOSCRIPT | CO::LOADING, 1, 0, 0, 0, acl::kList}.HFUNC(
List);
*registry << CI{"ACL SETUSER", CO::ADMIN | CO::NOSCRIPT | CO::LOADING, -2, 0, 0, 0, acl::kSetUser}
.HFUNC(SetUser);
}

#undef HFUNC
Expand Down
1 change: 1 addition & 0 deletions src/server/acl/acl_family.h
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class AclFamily {
private:
static void Acl(CmdArgList args, ConnectionContext* cntx);
static void List(CmdArgList args, ConnectionContext* cntx);
static void SetUser(CmdArgList args, ConnectionContext* cntx);
};

} // namespace acl
Expand Down
8 changes: 5 additions & 3 deletions src/server/acl/user_registry.cc
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
#include "server/acl/user_registry.h"

#include "core/fibers.h"
#include "facade/facade_types.h"
#include "server/acl/acl_commands_def.h"

namespace dfly::acl {
Expand Down Expand Up @@ -44,14 +45,15 @@ bool UserRegistry::IsUserActive(std::string_view username) const {
return it->second.IsActive();
}

bool UserRegistry::AuthUser(std::string_view username, std::string_view password) const {
std::pair<bool, const std::string_view> UserRegistry::AuthUser(std::string_view username,
std::string_view password) const {
std::shared_lock<util::SharedMutex> lock(mu_);
const auto& user = registry_.find(username);
if (user == registry_.end()) {
return false;
return {false, {}};
}

return user->second.HasPassword(password);
return {user->second.HasPassword(password) && user->second.IsActive(), user->first};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return {user->second.HasPassword(password) && user->second.IsActive(), user->first};
return {user->second.IsActive() && user->second.HasPassword(password), user->first};

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personal preference, but I don't mind the suggestion. I think the second is more likely than the first but oh well 🤷

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From a security perspective I don't want us to check the password if the user is disabled. It doesn't really matter but it's a bad practice IMO

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmmmm, what could go wrong?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'Reject illegal queries as soon as you can know they're illegal' is just a good security principal, even if it doesn't have immediate bad consequences.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes but you don't always engineer around security in mind -- any obsession over one principle can backfire pretty fast, imagine if C++ was designed with this in mind, no UB etc, it would be monstrous (and I don't bite the arguments about Rust being a better safe alternative this is so easy to debunk). That being said for execution paths that are guaranteed to be handled I don't see any problem. Again, I like to consider suggestions and since you think that this is a good principle I happily applied it 😄

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is code that handles authentication. It HAS to be designed with security in mind.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤣

}

UserRegistry::RegistryViewWithLock::RegistryViewWithLock(std::shared_lock<util::SharedMutex> mu,
Expand Down
3 changes: 2 additions & 1 deletion src/server/acl/user_registry.h
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ class UserRegistry {

// Acquires a read lock
// Used by Auth
bool AuthUser(std::string_view username, std::string_view password) const;
std::pair<bool, const std::string_view> AuthUser(std::string_view username,
std::string_view password) const;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a comment what the return value means (not obvious without looking at impl how to tell apart non-existing and existing but disabled user)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No needed anymore, we deep copy the username :)


// Helper class for accessing the registry with a ReadLock outside the scope of UserRegistry
class RegistryViewWithLock {
Expand Down
2 changes: 2 additions & 0 deletions src/server/conn_context.h
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ class ConnectionContext : public facade::ConnectionContext {
// Reference to a FlowInfo for this connection if from a master to a replica.
FlowInfo* replication_flow;

std::optional<std::string_view> authed_username;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIUC, the underlying std::string_view lives in the user registry and can be de-allocated when that user is deleted.

There's an issue in general of what happens to connections when their user is deleted. But this especially looks like a UAF bug waiting to happen.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope. That's not true.

  1. You can't change the username of a user once its created.
  2. You can only delete, but when that happens you first evict && close already opened connections so the lifetime semantics are guaranteed -- you will never access the name of the user once its released

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, but please document this next to the field.


private:
void EnableMonitoring(bool enable) {
subscriptions++; // required to support the monitoring
Expand Down
12 changes: 10 additions & 2 deletions src/server/server_family.cc
Original file line number Diff line number Diff line change
Expand Up @@ -1498,8 +1498,16 @@ void ServerFamily::Auth(CmdArgList args, ConnectionContext* cntx) {
return (*cntx)->SendError(kSyntaxErr);
}

if (args.size() == 3) {
return (*cntx)->SendError("ACL is not supported yet");
if (args.size() == 2) {
const auto* registry = ServerState::tlocal()->user_registry;
std::string_view username = facade::ToSV(args[0]);
std::string_view password = facade::ToSV(args[1]);
auto [is_authorized, user] = registry->AuthUser(username, password);
if (is_authorized) {
cntx->authed_username = user;
return (*cntx)->SendOk();
}
return (*cntx)->SendError(absl::StrCat("Could not authorize user: ", username));
}

if (!cntx->req_auth) {
Expand Down
58 changes: 57 additions & 1 deletion tests/dragonfly/acl_family_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,60 @@ async def test_acl_list_default_user(async_client):

result = await async_client.execute_command("ACL LIST")
assert 1 == len(result)
assert "user default on nopass +@all" == result[0]
assert "user default on nopass +@ALL" == result[0]


@pytest.mark.asyncio
async def test_acl_setuser(async_client):
# Bad input
with pytest.raises(redis.exceptions.ResponseError):
await async_client.execute_command("ACL SETUSER")

await async_client.execute_command("ACL SETUSER kostas")
result = await async_client.execute_command("ACL LIST")
assert 2 == len(result)
assert "user kostas off nopass +@NONE" in result

await async_client.execute_command("ACL SETUSER kostas ON")
result = await async_client.execute_command("ACL LIST")
assert "user kostas on nopass +@NONE" in result

await async_client.execute_command("ACL SETUSER kostas +@list +@string +@admin")
result = await async_client.execute_command("ACL LIST")
# TODO consider printing to lowercase
assert "user kostas on nopass +@LIST +@STRING +@ADMIN" in result

await async_client.execute_command("ACL SETUSER kostas -@list -@admin")
result = await async_client.execute_command("ACL LIST")
assert "user kostas on nopass +@STRING" in result

# mix and match
await async_client.execute_command("ACL SETUSER kostas +@list -@string")
result = await async_client.execute_command("ACL LIST")
assert "user kostas on nopass +@LIST" in result

await async_client.execute_command("ACL SETUSER kostas +@all")
result = await async_client.execute_command("ACL LIST")
assert "user kostas on nopass +@ALL" in result


@pytest.mark.asyncio
async def test_acl_auth(async_client):
await async_client.execute_command("ACL SETUSER kostas >mypass")

with pytest.raises(redis.exceptions.ResponseError):
await async_client.execute_command("AUTH kostas wrong_pass")

# This should fail because user is inactive
with pytest.raises(redis.exceptions.ResponseError):
await async_client.execute_command("AUTH kostas mypass")

# Activate user
await async_client.execute_command("ACL SETUSER kostas ON")

result = await async_client.execute_command("AUTH kostas mypass")
result == "ok"

# Let's also try default
result = await async_client.execute_command("AUTH default nopass")
result == "ok"