Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## [8.6.0] 2026-05-12

### Added

- [TD-8083] Add user group management features

## [8.3.0] 2026-03-12

### Changed
Expand Down
2 changes: 1 addition & 1 deletion lib/td_cache/templates/acl_loader.ex
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ defmodule TdCache.Templates.AclLoader do
|> Enum.map(fn group_id ->
case UserCache.get_group(group_id) do
{:ok, nil} -> nil
{:ok, group} -> Map.take(group, [:id, :name])
{:ok, group} -> Map.take(group, [:id, :name, :alias])
end
end)
|> Enum.reject(&is_nil/1)
Expand Down
12 changes: 11 additions & 1 deletion lib/td_cache/templates/field_formatter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ defmodule TdCache.Templates.FieldFormatter do
apply_role_meta(field, claims, role_name, user_roles)
end

def format(%{"type" => "group", "values" => %{"role_groups" => role_name}} = field, ctx) do
user_group_roles = Map.get(ctx, :user_group_roles, %{})
apply_user_group_meta(field, role_name, user_group_roles)
end

def format(%{"type" => "user_group", "values" => %{"role_groups" => role_name}} = field, ctx) do
claims = Map.get(ctx, :claims, nil)
user_roles = Map.get(ctx, :user_roles, %{})
Expand Down Expand Up @@ -77,10 +82,15 @@ defmodule TdCache.Templates.FieldFormatter do
)
when not is_nil(role_name) do
groups = Map.get(user_group_roles, role_name, [])
names = Enum.map(groups, & &1.name)
names = Enum.map(groups, &group_name_or_alias/1)
values = Map.put(values, "processed_groups", names)
Map.put(field, "values", values)
end

defp apply_user_group_meta(field, _role, _user_roles), do: field

defp group_name_or_alias(%{alias: group_alias, name: name}) when group_alias in [nil, ""],
do: name

defp group_name_or_alias(%{alias: group_alias}), do: group_alias
end
155 changes: 145 additions & 10 deletions lib/td_cache/user_cache.ex
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ defmodule TdCache.UserCache do
"user:#{id}:roles"
end

def user_group_name_to_id, do: "user_group:user_group_name_to_id"

def user_group(id) do
"user_group:#{id}"
end
Expand Down Expand Up @@ -118,6 +120,10 @@ defmodule TdCache.UserCache do
GenServer.call(__MODULE__, {:get_group, id})
end

def get_group_by_name(name) do
GenServer.call(__MODULE__, {:get_group_by_name, name})
end

def put(user) do
GenServer.call(__MODULE__, {:put, user})
end
Expand All @@ -142,6 +148,10 @@ defmodule TdCache.UserCache do
GenServer.call(__MODULE__, {:delete, id})
end

def clear_users_cache do
GenServer.call(__MODULE__, :clear_users_cache)
end

def put_group(group) do
GenServer.call(__MODULE__, {:put_group, group})
end
Expand All @@ -150,6 +160,10 @@ defmodule TdCache.UserCache do
GenServer.call(__MODULE__, {:delete_group, id})
end

def clear_groups_cache do
GenServer.call(__MODULE__, :clear_groups_cache)
end

def ids_key, do: Keys.ids()

def group_ids_key, do: Keys.group_ids()
Expand Down Expand Up @@ -191,6 +205,11 @@ defmodule TdCache.UserCache do
{:reply, {:ok, group}, state}
end

def handle_call({:get_group_by_name, name}, _from, state) do
group = read_group_by_name(name)
{:reply, {:ok, group}, state}
end

def handle_call({:put, user}, _from, state) do
reply = put_user(user)
{:reply, reply, state}
Expand Down Expand Up @@ -223,6 +242,11 @@ defmodule TdCache.UserCache do
{:reply, reply, state}
end

def handle_call(:clear_users_cache, _from, state) do
reply = do_clear_users_cache()
{:reply, reply, state}
end

def handle_call({:put_group, group}, _from, state) do
reply = do_put_group(group)
{:reply, reply, state}
Expand All @@ -233,6 +257,11 @@ defmodule TdCache.UserCache do
{:reply, reply, state}
end

def handle_call(:clear_groups_cache, _from, state) do
reply = do_clear_groups_cache()
{:reply, reply, state}
end

## Private functions

defp get_cache(key, fun) do
Expand Down Expand Up @@ -279,10 +308,22 @@ defmodule TdCache.UserCache do
end
end

defp read_group_by_name(names) when is_list(names), do: Enum.map(names, &read_group_by_name/1)

defp read_group_by_name(name) when is_binary(name) do
name_without_prefix = String.replace_prefix(name, "group:", "")

case Redix.command!(["HGET", Keys.user_group_name_to_id(), name_without_prefix]) do
nil -> nil
id -> read_group(id)
end
end

defp read_group(id) when is_binary(id) do
id
|> String.to_integer()
|> read_group()
case Integer.parse(id) do
{parsed_id, ""} -> read_group(parsed_id)
_ -> nil
end
end

defp read_group(id) do
Expand Down Expand Up @@ -416,20 +457,114 @@ defmodule TdCache.UserCache do
end)
end

defp do_put_group(%{id: id, name: name}) do
defp do_put_group(%{id: id, name: name, alias: group_alias} = group) do
[old_name, old_alias] = Redix.command!(["HMGET", Keys.user_group(id), "name", "alias"])

[
["DEL", Keys.user_group(id)],
["HSET", Keys.user_group(id), %{name: name}],
["HSET", Keys.user_group(id), %{name: name, alias: group_alias}],
["SADD", Keys.group_ids(), id]
]
|> remove_group_name_if_changed(old_name, name)
|> remove_group_name_if_changed(old_alias, group_alias)
|> add_group_name(group)
|> add_group_alias(group)
|> Redix.transaction_pipeline()
end

defp add_group_name(pipeline, %{id: id, name: name}) do
pipeline ++ [["HSET", Keys.user_group_name_to_id(), name, id]]
end

defp add_group_alias(pipeline, %{id: id, alias: group_alias})
when group_alias not in [nil, ""] do
pipeline ++ [["HSET", Keys.user_group_name_to_id(), group_alias, id]]
end

defp add_group_alias(pipeline, _), do: pipeline

defp remove_group_name_if_changed(pipeline, old_value, new_value)
when old_value not in [nil, ""] and old_value != new_value do
pipeline ++ [["HDEL", Keys.user_group_name_to_id(), old_value]]
end

defp remove_group_name_if_changed(pipeline, _old_value, _new_value), do: pipeline

defp do_delete_group(id) do
Redix.transaction_pipeline([
["DEL", Keys.user_group(id)],
["DEL", Keys.user_group_roles(id)],
["SREM", Keys.group_ids(), id]
])
case Redix.command!(["HMGET", Keys.user_group(id), "alias", "name"]) do
[nil, nil] ->
Redix.transaction_pipeline([
["DEL", Keys.user_group(id)],
["DEL", Keys.user_group_roles(id)],
["SREM", Keys.group_ids(), id]
])

[group_alias, name] ->
[
["DEL", Keys.user_group(id)],
["DEL", Keys.user_group_roles(id)],
["SREM", Keys.group_ids(), id]
]
|> delete_group_name(name)
|> delete_group_name(group_alias)
|> Redix.transaction_pipeline()
end
end

defp delete_group_name(pipeline, name) when name not in [nil, ""] do
pipeline ++ [["HDEL", Keys.user_group_name_to_id(), name]]
end

defp delete_group_name(pipeline, _), do: pipeline

defp do_clear_users_cache do
cached_ids = Redix.command!(["SMEMBERS", Keys.ids()])

user_keys =
["KEYS", "user:*"]
|> Redix.command!()
|> Enum.reject(&String.contains?(&1, ":roles"))

user_roles_keys = Redix.command!(["KEYS", "user:*:roles*"])

cmds =
[
["DEL", Keys.ids()],
["DEL", Keys.name_to_id()],
["DEL", Keys.user_name_to_id()],
["DEL", Keys.external_id_to_id()]
] ++
Enum.map(user_keys ++ user_roles_keys, &["DEL", &1])

reply = Redix.transaction_pipeline(cmds)

Enum.each(cached_ids, fn id ->
ConCache.delete(:users, id)

case Integer.parse(id) do
{int_id, ""} -> ConCache.delete(:users, int_id)
_ -> :ok
end
end)

reply
end

defp do_clear_groups_cache do
group_keys =
["KEYS", "user_group:*"]
|> Redix.command!()
|> Enum.reject(&String.ends_with?(&1, ":roles"))

group_roles_keys = Redix.command!(["KEYS", "user_group:*:roles"])

cmds =
[
["DEL", Keys.group_ids()],
["DEL", Keys.user_group_name_to_id()]
] ++
Enum.map(group_keys ++ group_roles_keys, &["DEL", &1])

Redix.transaction_pipeline(cmds)
end
end
2 changes: 1 addition & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ defmodule TdCache.MixProject do
def project do
[
app: :td_cache,
version: "8.3.0",
version: "8.6.0",
elixir: "~> 1.18",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
Expand Down
3 changes: 2 additions & 1 deletion test/support/factory.ex
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@ defmodule TdCache.Factory do
%{
id: unique_id(),
name: sequence("group_name"),
description: sequence("group_description")
description: sequence("group_description"),
alias: sequence("group_alias")
}
end

Expand Down
20 changes: 20 additions & 0 deletions test/td_cache/templates/acl_loader_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,16 @@ defmodule TdCache.Templates.AclLoaderTest do
%{id: user_id_2, full_name: user_name_2}
]
end

test "ignores users not present in cache" do
domain = CacheHelpers.insert_domain()
missing_user_id = System.unique_integer([:positive])

AclCache.set_acl_roles("domain", domain.id, ["role1"])
AclCache.set_acl_role_users("domain", domain.id, "role1", [missing_user_id])

assert %{"role1" => []} = AclLoader.get_roles_and_users([domain.id])
end
end

describe "get_roles_and_groups/1" do
Expand Down Expand Up @@ -79,5 +89,15 @@ defmodule TdCache.Templates.AclLoaderTest do

assert Enum.sort([id1, id2]) == Enum.sort([group_id_1, group_id_2])
end

test "ignores groups not present in cache" do
domain = CacheHelpers.insert_domain()
missing_group_id = System.unique_integer([:positive])

AclCache.set_acl_group_roles("domain", domain.id, ["role1"])
AclCache.set_acl_role_groups("domain", domain.id, "role1", [missing_group_id])

assert %{"role1" => []} = AclLoader.get_roles_and_groups([domain.id])
end
end
end
Loading
Loading