1
0
Fork 0
mirror of https://github.com/dragonflydb/dragonfly.git synced 2024-12-14 11:58:02 +00:00

feat: add validation of acl users (#1743)

* add validation for categories
* add tests
This commit is contained in:
Kostas Kyrimis 2023-08-29 18:52:46 +03:00 committed by GitHub
parent 2881ca99e4
commit 7c43cbf2b5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 182 additions and 18 deletions

View file

@ -3,6 +3,8 @@
//
//
#pragma once
template <class... Ts> struct Overloaded : Ts... { using Ts::operator()...; };
template <class... Ts> Overloaded(Ts...) -> Overloaded<Ts...>;

View file

@ -25,7 +25,8 @@ add_library(dragonfly_lib channel_store.cc command_registry.cc
zset_family.cc version.cc bitops_family.cc container_utils.cc io_utils.cc
serializer_commons.cc journal/serializer.cc journal/executor.cc journal/streamer.cc
top_keys.cc multi_command_squasher.cc hll_family.cc cluster/cluster_config.cc
cluster/cluster_family.cc acl/user.cc acl/user_registry.cc acl/acl_family.cc)
cluster/cluster_family.cc acl/user.cc acl/user_registry.cc acl/acl_family.cc
acl/validator.cc)
cxx_link(dragonfly_lib dfly_transaction dfly_facade redis_lib aws_lib strings_lib html_lib

View file

@ -16,10 +16,6 @@
namespace dfly::acl {
// TODO implement these
//#bool CheckIfCommandAllowed(uint64_t command_id, const CommandId& command);
//#bool CheckIfAclCategoryAllowed(uint64_t command_id, const CommandId& command);
class User final {
public:
struct UpdateRequest {

View file

@ -0,0 +1,19 @@
// Copyright 2023, DragonflyDB authors. All rights reserved.
// See LICENSE for licensing terms.
//
#include "server/acl/validator.h"
#include "server/server_state.h"
namespace dfly::acl {
[[nodiscard]] bool IsUserAllowedToInvokeCommand(const ConnectionContext& cntx,
const facade::CommandId& id) {
auto& registry = *ServerState::tlocal()->user_registry;
auto credentials = registry.GetCredentials(cntx.authed_username);
auto command_credentials = id.acl_categories();
return (credentials.acl_categories & command_credentials) != 0;
}
} // namespace dfly::acl

View file

@ -0,0 +1,16 @@
// Copyright 2023, DragonflyDB authors. All rights reserved.
// See LICENSE for licensing terms.
//
#pragma once
#include <string_view>
#include "facade/command_id.h"
#include "server/conn_context.h"
namespace dfly::acl {
bool IsUserAllowedToInvokeCommand(const ConnectionContext& cntx, const facade::CommandId& id);
}

View file

@ -196,7 +196,7 @@ 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> authed_username;
std::string authed_username{"default"};
private:
void EnableMonitoring(bool enable) {

View file

@ -27,6 +27,7 @@ extern "C" {
#include "facade/reply_capture.h"
#include "server/acl/acl_commands_def.h"
#include "server/acl/acl_family.h"
#include "server/acl/validator.h"
#include "server/bitops_family.h"
#include "server/cluster/cluster_family.h"
#include "server/conn_context.h"
@ -784,11 +785,27 @@ OpStatus CheckKeysDeclared(const ConnectionState::ScriptInfo& eval_info, const C
return OpStatus::OK;
}
optional<ErrorReply> Service::VerifyCommandExecution(const CommandId* cid) {
// TODO: Move OOM check here
static optional<ErrorReply> VerifyConnectionAclStatus(const CommandId* cid,
const ConnectionContext* cntx,
string_view error_msg) {
// If we are on a squashed context we need to use the owner, because the
// context we are operating on is a stub and the acl username is not copied
// See: MultiCommandSquasher::SquashedHopCb
if (cntx->conn_state.squashing_info)
cntx = cntx->conn_state.squashing_info->owner;
if (!acl::IsUserAllowedToInvokeCommand(*cntx, *cid)) {
return ErrorReply(absl::StrCat("NOPERM: ", cntx->authed_username, " ", error_msg));
}
return nullopt;
}
optional<ErrorReply> Service::VerifyCommandExecution(const CommandId* cid,
const ConnectionContext* cntx) {
// TODO: Move OOM check here
return VerifyConnectionAclStatus(cid, cntx, "ACL rules changed between the MULTI and EXEC");
}
std::optional<ErrorReply> Service::VerifyCommandState(const CommandId* cid, CmdArgList tail_args,
const ConnectionContext& dfly_cntx) {
DCHECK(cid);
@ -866,7 +883,7 @@ std::optional<ErrorReply> Service::VerifyCommandState(const CommandId* cid, CmdA
return ErrorReply{status};
}
return nullopt;
return VerifyConnectionAclStatus(cid, &dfly_cntx, "has no ACL permissions");
}
void Service::DispatchCommand(CmdArgList args, facade::ConnectionContext* cntx) {
@ -980,7 +997,7 @@ bool Service::InvokeCmd(const CommandId* cid, CmdArgList tail_args, ConnectionCo
DCHECK(cid);
DCHECK(!cid->Validate(tail_args));
if (auto err = VerifyCommandExecution(cid); err) {
if (auto err = VerifyCommandExecution(cid, cntx); err) {
(*cntx)->SendError(std::move(*err));
return true; // return false only for internal error aborts
}

View file

@ -55,7 +55,8 @@ class Service : public facade::ServiceInterface {
// Verify command can be executed now (check out of memory), always called immediately before
// execution
std::optional<facade::ErrorReply> VerifyCommandExecution(const CommandId* cid);
std::optional<facade::ErrorReply> VerifyCommandExecution(const CommandId* cid,
const ConnectionContext* cntx);
// Verify command prepares excution in correct state.
// It's usually called before command execution. Only for multi/exec transactions it's checked

View file

@ -59,21 +59,133 @@ async def test_acl_setuser(async_client):
@pytest.mark.asyncio
async def test_acl_auth(async_client):
await async_client.execute_command("ACL SETUSER kostas >mypass")
await async_client.execute_command("ACL SETUSER shahar >mypass")
with pytest.raises(redis.exceptions.ResponseError):
await async_client.execute_command("AUTH kostas wrong_pass")
await async_client.execute_command("AUTH shahar wrong_pass")
# This should fail because user is inactive
with pytest.raises(redis.exceptions.ResponseError):
await async_client.execute_command("AUTH kostas mypass")
await async_client.execute_command("AUTH shahar mypass")
# Activate user
await async_client.execute_command("ACL SETUSER kostas ON")
await async_client.execute_command("ACL SETUSER shahar ON +@fast")
result = await async_client.execute_command("AUTH kostas mypass")
result == "ok"
result = await async_client.execute_command("AUTH shahar mypass")
assert result == "OK"
# Let's also try default
result = await async_client.execute_command("AUTH default nopass")
result == "ok"
assert result == "OK"
@pytest.mark.asyncio
async def test_acl_categories(async_client):
await async_client.execute_command("ACL SETUSER vlad ON >mypass +@string +@list +@connection")
result = await async_client.execute_command("AUTH vlad mypass")
assert result == "OK"
result = await async_client.execute_command("SET foo bar")
assert result == "OK"
result = await async_client.execute_command("LPUSH mykey space_monkey")
assert result == 1
# This should fail, vlad does not have @admin
with pytest.raises(redis.exceptions.ResponseError):
await async_client.execute_command("ACL SETUSER vlad ON >mypass")
# This should fail, vlad does not have @sortedset
with pytest.raises(redis.exceptions.ResponseError):
await async_client.execute_command("ZADD myset 1 two")
result = await async_client.execute_command("AUTH default nopass")
assert result == "OK"
# Make vlad an admin
await async_client.execute_command("ACL SETUSER vlad -@string")
assert result == "OK"
result = await async_client.execute_command("AUTH vlad mypass")
assert result == "OK"
with pytest.raises(redis.exceptions.ResponseError):
await async_client.execute_command("GET foo")
result = await async_client.execute_command("AUTH default nopass")
assert result == "OK"
# Vlad goes rogue starts giving admin stats to random users
await async_client.execute_command("ACL SETUSER adi >adi +@admin")
assert result == "OK"
# Vlad can now execute everything
await async_client.execute_command("ACL SETUSER vlad +@all")
assert result == "OK"
await async_client.execute_command("ZADD myset 1 two")
assert result == "OK"
@pytest.mark.asyncio
async def test_acl_categories_multi_exec_squash(df_local_factory):
df = df_local_factory.create(multi_exec_squash=True, port=1111)
df.start()
client = aioredis.Redis(port=df.port)
res = await client.execute_command("ACL SETUSER kk ON >kk +@transaction +@string")
assert res == b"OK"
res = await client.execute_command("AUTH kk kk")
assert res == b"OK"
await client.execute_command("MULTI")
assert res == b"OK"
for x in range(33):
await client.execute_command(f"SET x{x} {x}")
await client.execute_command("EXEC")
client = aioredis.Redis(port=df.port)
await client.close()
# NOPERM while executing multi
await client.execute_command("ACL SETUSER kk -@string")
assert res == b"OK"
await client.execute_command("AUTH kk kk")
assert res == b"OK"
await client.execute_command("MULTI")
assert res == b"OK"
with pytest.raises(redis.exceptions.ResponseError):
await client.execute_command(f"SET x{x} {x}")
await client.close()
# NOPERM between multi and exec
admin_client = aioredis.Redis(port=df.port)
res = await client.execute_command("ACL SETUSER kk +@string")
assert res == b"OK"
client = aioredis.Redis(port=df.port)
res = await client.execute_command("AUTH kk kk")
assert res == b"OK"
# CLIENT has permissions, starts MULTI and issues a bunch of SET commands
await client.execute_command("MULTI")
assert res == b"OK"
for x in range(33):
await client.execute_command(f"SET x{x} {x}")
# ADMIN revokes permissions
res = await admin_client.execute_command("ACL SETUSER kk -@string")
assert res == b"OK"
res = await client.execute_command("EXEC")
# TODO(we need to fix this, basiscally SQUASHED/MULTI transaction commands
# return multiple errors for each command failed. Since the nature of the error
# is the same, that a rule has changed we should squash those error messages into
# one.
assert res[0].args[0] == "NOPERM: kk ACL rules changed between the MULTI and EXEC"
await admin_client.close()
await client.close()