Skip to content

Commit 2ac83f1

Browse files
committed
add sed-style groups (roles) transform
1 parent 06c7142 commit 2ac83f1

File tree

3 files changed

+141
-3
lines changed

3 files changed

+141
-3
lines changed

docs/en/operations/external-authenticators/tokens.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@ All this implies that the SQL-driven [Access Control and Account Management](/do
237237
<roles_filter>
238238
\bclickhouse-[a-zA-Z0-9]+\b
239239
</roles_filter>
240+
<roles_transform>s/-/_/g</roles_transform>
240241
</token>
241242
</user_directories>
242243
</clickhouse>
@@ -251,3 +252,4 @@ For now, no more than one `token` section can be defined inside `user_directorie
251252
- `processor` — Name of one of processors defined in `token_processors` config section described above. This parameter is mandatory and cannot be empty.
252253
- `common_roles` — Section with a list of locally defined roles that will be assigned to each user retrieved from the IdP. Optional.
253254
- `roles_filter` — Regex string for groups filtering. Only groups matching this regex will be mapped to roles. Optional.
255+
- `roles_transform` — Sed-style transform pattern to apply to group names before mapping to roles. Format: `s/pattern/replacement/flags`. The `g` flag applies the replacement globally (all occurrences). Example: `s/-/_/g` converts `clickhouse-grp-dba` to `clickhouse_grp_dba`. Optional.

src/Access/TokenAccessStorage.cpp

Lines changed: 136 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,115 @@ namespace ErrorCodes
1818
extern const int BAD_ARGUMENTS;
1919
}
2020

21+
namespace
22+
{
23+
struct ParsedTransform
24+
{
25+
String pattern;
26+
String replacement;
27+
bool global;
28+
};
29+
30+
/// Unescape a string segment
31+
String unescapeSegment(const String & str, size_t start, size_t end)
32+
{
33+
String result;
34+
result.reserve(end - start);
35+
bool escaped = false;
36+
37+
for (size_t i = start; i < end; ++i)
38+
{
39+
if (escaped)
40+
{
41+
result += str[i];
42+
escaped = false;
43+
}
44+
else if (str[i] == '\\')
45+
escaped = true;
46+
else
47+
result += str[i];
48+
}
49+
50+
return result;
51+
}
52+
53+
/// Parse sed-style transform pattern: s/pattern/replacement/flags
54+
ParsedTransform parseSedTransform(const String & transform)
55+
{
56+
if (transform.size() < 4 || transform[0] != 's' || transform[1] != '/')
57+
{
58+
throw Exception(ErrorCodes::BAD_ARGUMENTS, "Invalid roles_transform format. Expected sed-style pattern like 's/pattern/replacement/g'");
59+
}
60+
61+
bool escaped = false;
62+
size_t first_slash = 1;
63+
size_t second_slash = String::npos;
64+
size_t third_slash = String::npos;
65+
66+
// Find delimiters using simple state machine
67+
for (size_t i = first_slash + 1; i < transform.size(); ++i)
68+
{
69+
if (escaped)
70+
{
71+
escaped = false;
72+
continue;
73+
}
74+
75+
if (transform[i] == '\\')
76+
{
77+
escaped = true;
78+
continue;
79+
}
80+
81+
if (transform[i] == '/')
82+
{
83+
if (second_slash == String::npos)
84+
second_slash = i;
85+
else if (third_slash == String::npos)
86+
third_slash = i;
87+
else
88+
throw Exception(ErrorCodes::BAD_ARGUMENTS, "Invalid roles_transform format. Too many unescaped slashes. Expected sed-style pattern like 's/pattern/replacement/g'");
89+
}
90+
}
91+
92+
if (second_slash == String::npos || third_slash == String::npos)
93+
throw Exception(ErrorCodes::BAD_ARGUMENTS, "Invalid roles_transform format. Expected sed-style pattern like 's/pattern/replacement/g'");
94+
95+
ParsedTransform result;
96+
97+
result.pattern = unescapeSegment(transform, first_slash + 1, second_slash);
98+
99+
size_t replacement_end = (third_slash != String::npos) ? third_slash : transform.size();
100+
result.replacement = unescapeSegment(transform, second_slash + 1, replacement_end);
101+
102+
String flags = transform.substr(third_slash + 1);
103+
result.global = (flags.find('g') != String::npos);
104+
105+
return result;
106+
}
107+
108+
String applyTransform(const String & input, const String & pattern, const String & replacement, bool global)
109+
{
110+
if (pattern.empty())
111+
return input;
112+
113+
re2::RE2 re(pattern);
114+
if (!re.ok())
115+
return input;
116+
117+
String result = input;
118+
if (global)
119+
{
120+
RE2::GlobalReplace(&result, re, replacement);
121+
}
122+
else
123+
{
124+
RE2::Replace(&result, re, replacement);
125+
}
126+
return result;
127+
}
128+
}
129+
21130
TokenAccessStorage::TokenAccessStorage(const String & storage_name_, AccessControl & access_control_, const Poco::Util::AbstractConfiguration & config_, const String & prefix_)
22131
: IAccessStorage(storage_name_), access_control(access_control_), config(config_), prefix(prefix_),
23132
memory_storage(storage_name_, access_control.getChangesNotifier(), false)
@@ -29,6 +138,15 @@ TokenAccessStorage::TokenAccessStorage(const String & storage_name_, AccessContr
29138
if (config.has(prefix_str + "roles_filter"))
30139
roles_filter.emplace(config.getString(prefix_str + "roles_filter"));
31140

141+
if (config.has(prefix_str + "roles_transform"))
142+
{
143+
String transform = config.getString(prefix_str + "roles_transform");
144+
ParsedTransform parsed = parseSedTransform(transform);
145+
roles_transform_pattern = parsed.pattern;
146+
roles_transform_replacement = parsed.replacement;
147+
roles_transform_global = parsed.global;
148+
}
149+
32150
provider_name = config.getString(prefix_str + "processor");
33151
if (provider_name.empty())
34152
throw Exception(ErrorCodes::BAD_ARGUMENTS, "'processor' must be specified for Token user directory");
@@ -374,15 +492,30 @@ std::optional<AuthResult> TokenAccessStorage::authenticateImpl(
374492
for (const auto & group: token_credentials.getGroups()) {
375493
if (RE2::FullMatch(group, roles_filter.value()))
376494
{
377-
external_roles.insert(group);
378-
LOG_TRACE(getLogger(), "{}: Granted role (group) {} to user", getStorageName(), user->getName());
495+
String transformed_group = group;
496+
if (roles_transform_pattern.has_value() && roles_transform_replacement.has_value())
497+
{
498+
transformed_group = applyTransform(group, roles_transform_pattern.value(), roles_transform_replacement.value(), roles_transform_global);
499+
LOG_TRACE(getLogger(), "{}: Transformed group '{}' to '{}'", getStorageName(), group, transformed_group);
500+
}
501+
external_roles.insert(transformed_group);
502+
LOG_TRACE(getLogger(), "{}: Granted role (group) {} to user", getStorageName(), transformed_group);
379503
}
380504
}
381505
}
382506
else
383507
{
384508
LOG_TRACE(getLogger(), "{}: No external role filtering set, applying all available groups", getStorageName());
385-
external_roles = token_credentials.getGroups();
509+
for (const auto & group: token_credentials.getGroups())
510+
{
511+
String transformed_group = group;
512+
if (roles_transform_pattern.has_value() && roles_transform_replacement.has_value())
513+
{
514+
transformed_group = applyTransform(group, roles_transform_pattern.value(), roles_transform_replacement.value(), roles_transform_global);
515+
LOG_TRACE(getLogger(), "{}: Transformed group '{}' to '{}'", getStorageName(), group, transformed_group);
516+
}
517+
external_roles.insert(transformed_group);
518+
}
386519
}
387520

388521
if (new_user)

src/Access/TokenAccessStorage.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ class TokenAccessStorage : public IAccessStorage
4848

4949
String provider_name;
5050
std::optional<re2::RE2> roles_filter = std::nullopt;
51+
std::optional<String> roles_transform_pattern = std::nullopt;
52+
std::optional<String> roles_transform_replacement = std::nullopt;
53+
bool roles_transform_global = false;
5154

5255
std::set<String> common_role_names; // role name that should be granted to all users at all times
5356
mutable std::map<String, std::set<String>> user_external_roles;

0 commit comments

Comments
 (0)