diff --git a/CHANGELOG.md b/CHANGELOG.md index 290db4c..eea2bd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/td_cache/templates/acl_loader.ex b/lib/td_cache/templates/acl_loader.ex index 986f6a1..9d74c42 100644 --- a/lib/td_cache/templates/acl_loader.ex +++ b/lib/td_cache/templates/acl_loader.ex @@ -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) diff --git a/lib/td_cache/templates/field_formatter.ex b/lib/td_cache/templates/field_formatter.ex index 8a105b0..d2a8b52 100644 --- a/lib/td_cache/templates/field_formatter.ex +++ b/lib/td_cache/templates/field_formatter.ex @@ -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, %{}) @@ -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 diff --git a/lib/td_cache/user_cache.ex b/lib/td_cache/user_cache.ex index 8bd3130..9c24142 100644 --- a/lib/td_cache/user_cache.ex +++ b/lib/td_cache/user_cache.ex @@ -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 @@ -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 @@ -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 @@ -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() @@ -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} @@ -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} @@ -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 @@ -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 @@ -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 diff --git a/mix.exs b/mix.exs index 799057b..d92da33 100644 --- a/mix.exs +++ b/mix.exs @@ -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, diff --git a/test/support/factory.ex b/test/support/factory.ex index ac10698..266b846 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -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 diff --git a/test/td_cache/templates/acl_loader_test.exs b/test/td_cache/templates/acl_loader_test.exs index 968fcb4..50c7e19 100644 --- a/test/td_cache/templates/acl_loader_test.exs +++ b/test/td_cache/templates/acl_loader_test.exs @@ -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 @@ -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 diff --git a/test/td_cache/templates/preprocessor_test.exs b/test/td_cache/templates/preprocessor_test.exs index c841df8..af21ee2 100644 --- a/test/td_cache/templates/preprocessor_test.exs +++ b/test/td_cache/templates/preprocessor_test.exs @@ -52,7 +52,7 @@ defmodule TdCache.Templates.PreprocessorTest do test "preprocess_template/2 enriches user_group role fields" do %{id: domain_id} = CacheHelpers.insert_domain() %{id: user_id, full_name: full_name} = CacheHelpers.insert_user() - %{id: group_id, name: group_name} = CacheHelpers.insert_group() + %{id: group_id, alias: group_alias} = CacheHelpers.insert_group() AclCache.set_acl_roles("domain", domain_id, [@role_name]) AclCache.set_acl_group_roles("domain", domain_id, [@role_name]) @@ -83,7 +83,7 @@ defmodule TdCache.Templates.PreprocessorTest do "values" => %{ "role_groups" => @role_name, "processed_users" => [full_name], - "processed_groups" => [group_name] + "processed_groups" => [group_alias] } } @@ -131,7 +131,7 @@ defmodule TdCache.Templates.PreprocessorTest do test "preprocess_template/2 process dynamic table type fields" do %{id: domain_id} = CacheHelpers.insert_domain() %{id: user_id, full_name: full_name} = CacheHelpers.insert_user() - %{id: group_id, name: group_name} = CacheHelpers.insert_group() + %{id: group_id, alias: group_alias} = CacheHelpers.insert_group() AclCache.set_acl_roles("domain", domain_id, [@role_name]) AclCache.set_acl_group_roles("domain", domain_id, [@role_name]) @@ -206,11 +206,46 @@ defmodule TdCache.Templates.PreprocessorTest do "name" => "user_group_field_col", "type" => "user_group", "values" => %{ - "processed_groups" => [group_name], + "processed_groups" => [group_alias], "processed_users" => [full_name], "role_groups" => "foo_role" } } end + + test "preprocess_template/2 enriches group role fields with groups only" do + %{id: domain_id} = CacheHelpers.insert_domain() + %{id: user_id} = CacheHelpers.insert_user() + %{id: group_id, alias: group_alias} = CacheHelpers.insert_group() + + AclCache.set_acl_roles("domain", domain_id, [@role_name]) + AclCache.set_acl_group_roles("domain", domain_id, [@role_name]) + AclCache.set_acl_role_users("domain", domain_id, @role_name, [user_id]) + AclCache.set_acl_role_groups("domain", domain_id, @role_name, [group_id]) + + ctx = %{domain_ids: [domain_id], claims: %{user_id: user_id}} + + fields = [ + %{ + "name" => "group_field", + "type" => "group", + "values" => %{"role_groups" => @role_name} + } + ] + + template = %{content: [%{"name" => "group1", "fields" => fields}]} + + assert %{content: [%{"fields" => [group_field]}]} = + Preprocessor.preprocess_template(template, ctx) + + assert group_field == %{ + "name" => "group_field", + "type" => "group", + "values" => %{ + "role_groups" => @role_name, + "processed_groups" => [group_alias] + } + } + end end end diff --git a/test/td_cache/user_cache_test.exs b/test/td_cache/user_cache_test.exs index b00c567..da07d13 100644 --- a/test/td_cache/user_cache_test.exs +++ b/test/td_cache/user_cache_test.exs @@ -119,6 +119,36 @@ defmodule TdCache.UserCacheTest do assert UserCache.id_to_email_map() == %{} end + + test "clear_users_cache removes all user cache keys and keeps group keys" do + user = build(:user) + other_user = build(:user) + group = build(:group) + + put_user(user) + put_user(other_user) + put_user_group(group) + + assert {:ok, %{id: id}} = UserCache.get(user.id) + assert id == user.id + assert {:ok, %{id: id}} = UserCache.get(other_user.id) + assert id == other_user.id + assert {:ok, %{id: id}} = UserCache.get_by_name(user.full_name) + assert id == user.id + assert {:ok, %{id: id}} = UserCache.get_by_user_name(other_user.user_name) + assert id == other_user.id + assert {:ok, %{id: id}} = UserCache.get_group(group.id) + assert id == group.id + + assert {:ok, _} = UserCache.clear_users_cache() + + assert {:ok, nil} = UserCache.get(user.id) + assert {:ok, nil} = UserCache.get(other_user.id) + assert {:ok, nil} = UserCache.get_by_name(user.full_name) + assert {:ok, nil} = UserCache.get_by_user_name(other_user.user_name) + assert {:ok, %{id: id}} = UserCache.get_group(group.id) + assert id == group.id + end end describe "refresh_all_roles/1 refresh_resource_roles/3 and get_roles/1" do @@ -250,8 +280,192 @@ defmodule TdCache.UserCacheTest do end end + describe "user_groups" do + test "put_group returns OK" do + group = build(:group) + assert {:ok, [_, 2, 1, 1, 1]} = put_user_group(group) + end + + test "get_group returns a map with name and alias" do + group = build(:group) + put_user_group(group) + {:ok, g} = UserCache.get_group(group.id) + assert g == Map.take(group, [:name, :alias, :id]) + end + + test "get_group_by_name returns a map with name and alias " do + group = build(:group) + put_user_group(group) + {:ok, g} = UserCache.get_group_by_name(group.name) + assert g == Map.take(group, [:name, :alias, :id]) + end + + test "get_group_by_name returns a group by alias" do + group = build(:group) + put_user_group(group) + + {:ok, g} = UserCache.get_group_by_name(group.alias) + + assert g == Map.take(group, [:name, :alias, :id]) + end + + test "get_group_by_name returns groups from a list of names" do + group1 = build(:group) + group2 = build(:group) + put_user_group(group1) + put_user_group(group2) + + {:ok, groups} = UserCache.get_group_by_name([group1.name, group2.name]) + + assert groups == [ + Map.take(group1, [:name, :alias, :id]), + Map.take(group2, [:name, :alias, :id]) + ] + end + + test "get_group_by_name returns groups from a list of group names and user names" do + group1 = build(:group) + group2 = build(:group) + put_user_group(group1) + put_user_group(group2) + + user1 = build(:user) + user2 = build(:user) + put_user(user1) + put_user(user2) + + {:ok, groups} = + UserCache.get_group_by_name([group1.name, group2.name, user1.user_name, user2.user_name]) + + assert groups == [ + Map.take(group1, [:name, :alias, :id]), + Map.take(group2, [:name, :alias, :id]), + nil, + nil + ] + end + + test "put_group updates name and alias indexes when values change" do + group = build(:group) + put_user_group(group) + + updated_group = %{ + group + | name: "#{group.name}_updated", + alias: "#{group.alias}_updated" + } + + assert {:ok, _} = UserCache.put_group(updated_group) + + assert {:ok, nil} = UserCache.get_group_by_name(group.name) + assert {:ok, nil} = UserCache.get_group_by_name(group.alias) + + assert {:ok, %{id: id}} = UserCache.get_group_by_name(updated_group.name) + assert id == group.id + assert {:ok, %{id: id}} = UserCache.get_group_by_name(updated_group.alias) + assert id == group.id + end + + test "put_group removes previous alias index when alias becomes nil" do + group = build(:group) + put_user_group(group) + + updated_group = %{group | alias: nil} + assert {:ok, _} = UserCache.put_group(updated_group) + + assert {:ok, nil} = UserCache.get_group_by_name(group.alias) + assert {:ok, %{id: id}} = UserCache.get_group_by_name(group.name) + assert id == group.id + end + + test "delete_group removes group lookups by name and alias" do + group = build(:group) + put_user_group(group) + + assert {:ok, %{id: id}} = UserCache.get_group_by_name(group.name) + assert id == group.id + assert {:ok, %{id: id}} = UserCache.get_group_by_name(group.alias) + assert id == group.id + + assert {:ok, _} = UserCache.delete_group(group.id) + + assert {:ok, nil} = UserCache.get_group(group.id) + assert {:ok, nil} = UserCache.get_group_by_name(group.name) + assert {:ok, nil} = UserCache.get_group_by_name(group.alias) + end + + test "delete_group removes group name lookup when alias is nil" do + group = :group |> build() |> Map.put(:alias, nil) + + put_user_group(group) + + assert {:ok, %{id: id}} = UserCache.get_group_by_name(group.name) + assert id == group.id + + assert {:ok, _} = UserCache.delete_group(group.id) + + assert {:ok, nil} = UserCache.get_group(group.id) + assert {:ok, nil} = UserCache.get_group_by_name(group.name) + end + + test "delete_group does not delete user cache with same id" do + group = build(:group) + + user = :user |> build() |> Map.put(:id, group.id) + + put_user_group(group) + put_user(user) + + assert {:ok, %{id: id}} = UserCache.get_group(group.id) + assert id == group.id + assert {:ok, %{id: id}} = UserCache.get(user.id) + assert id == user.id + + assert {:ok, _} = UserCache.delete_group(group.id) + + assert {:ok, nil} = UserCache.get_group(group.id) + assert {:ok, %{id: id}} = UserCache.get(user.id) + assert id == user.id + end + + test "clear_groups_cache removes all group cache keys and keeps user keys" do + group = build(:group) + other_group = build(:group) + user = build(:user) + + put_user_group(group) + put_user_group(other_group) + put_user(user) + + assert {:ok, %{id: id}} = UserCache.get_group(group.id) + assert id == group.id + assert {:ok, %{id: id}} = UserCache.get_group(other_group.id) + assert id == other_group.id + assert {:ok, %{id: id}} = UserCache.get_group_by_name(group.name) + assert id == group.id + assert {:ok, %{id: id}} = UserCache.get_group_by_name(other_group.alias) + assert id == other_group.id + assert {:ok, %{id: id}} = UserCache.get(user.id) + assert id == user.id + + assert {:ok, _} = UserCache.clear_groups_cache() + + assert {:ok, nil} = UserCache.get_group(group.id) + assert {:ok, nil} = UserCache.get_group(other_group.id) + assert {:ok, nil} = UserCache.get_group_by_name(group.name) + assert {:ok, nil} = UserCache.get_group_by_name(other_group.alias) + assert {:ok, %{id: id}} = UserCache.get(user.id) + assert id == user.id + end + end + defp put_user(%{id: id} = user) do on_exit(fn -> UserCache.delete(id) end) UserCache.put(user) end + + defp put_user_group(%{id: id} = group) do + on_exit(fn -> UserCache.delete_group(id) end) + UserCache.put_group(group) + end end