From 63de0efb73c8fd8a0aafb11a3a196505684e1698 Mon Sep 17 00:00:00 2001 From: Andrew Dryga Date: Fri, 2 Aug 2024 01:49:44 -0600 Subject: [PATCH] feat(portal): Time based policies (#6115) Flows authorized by time-based policies will now expire at the latest time permitted by the policy. --- elixir/apps/domain/lib/domain/flows.ex | 12 +- elixir/apps/domain/lib/domain/policies.ex | 4 +- .../domain/policies/condition/evaluator.ex | 147 +++++++-- elixir/apps/domain/test/domain/flows_test.exs | 19 +- .../policies/condition/evaluator_test.exs | 285 +++++++++++++++--- .../apps/domain/test/domain/policies_test.exs | 2 +- .../web/lib/web/live/policies/components.ex | 217 ++++++------- .../web/test/web/live/policies/new_test.exs | 57 ++++ website/src/app/kb/deploy/policies/readme.mdx | 18 ++ 9 files changed, 567 insertions(+), 194 deletions(-) diff --git a/elixir/apps/domain/lib/domain/flows.ex b/elixir/apps/domain/lib/domain/flows.ex index 4a22c99f5..8a6fd229b 100644 --- a/elixir/apps/domain/lib/domain/flows.ex +++ b/elixir/apps/domain/lib/domain/flows.ex @@ -32,7 +32,7 @@ defmodule Domain.Flows do with :ok <- Auth.ensure_has_permissions(subject, Authorizer.create_flows_permission()), {:ok, resource} <- Resources.fetch_and_authorize_resource_by_id(resource_id, subject, opts), - {:ok, policy} <- fetch_conforming_policy(resource, client) do + {:ok, policy, conformation_expires_at} <- fetch_conforming_policy(resource, client) do flow = Flow.Changeset.create(%{ token_id: token_id, @@ -44,7 +44,7 @@ defmodule Domain.Flows do client_remote_ip: client_remote_ip, client_user_agent: client_user_agent, gateway_remote_ip: gateway_remote_ip, - expires_at: expires_at + expires_at: conformation_expires_at || expires_at }) |> Repo.insert!() @@ -55,8 +55,8 @@ defmodule Domain.Flows do defp fetch_conforming_policy(%Resources.Resource{} = resource, client) do Enum.reduce_while(resource.authorized_by_policies, {:error, []}, fn policy, {:error, acc} -> case Policies.ensure_client_conforms_policy_conditions(client, policy) do - :ok -> - {:halt, {:ok, policy}} + {:ok, expires_at} -> + {:halt, {:ok, policy, expires_at}} {:error, {:forbidden, violated_properties: violated_properties}} -> {:cont, {:error, violated_properties ++ acc}} @@ -66,8 +66,8 @@ defmodule Domain.Flows do {:error, violated_properties} -> {:error, {:forbidden, violated_properties: violated_properties}} - {:ok, policy} -> - {:ok, policy} + {:ok, policy, expires_at} -> + {:ok, policy, expires_at} end end diff --git a/elixir/apps/domain/lib/domain/policies.ex b/elixir/apps/domain/lib/domain/policies.ex index e2e750b01..57c960614 100644 --- a/elixir/apps/domain/lib/domain/policies.ex +++ b/elixir/apps/domain/lib/domain/policies.ex @@ -167,8 +167,8 @@ defmodule Domain.Policies do def ensure_client_conforms_policy_conditions(%Clients.Client{} = client, %Policy{} = policy) do case Condition.Evaluator.ensure_conforms(policy.conditions, client) do - :ok -> - :ok + {:ok, expires_at} -> + {:ok, expires_at} {:error, violated_properties} -> {:error, {:forbidden, violated_properties: violated_properties}} diff --git a/elixir/apps/domain/lib/domain/policies/condition/evaluator.ex b/elixir/apps/domain/lib/domain/policies/condition/evaluator.ex index 29d2af61d..818a95651 100644 --- a/elixir/apps/domain/lib/domain/policies/condition/evaluator.ex +++ b/elixir/apps/domain/lib/domain/policies/condition/evaluator.ex @@ -6,79 +6,118 @@ defmodule Domain.Policies.Condition.Evaluator do @days_of_week ~w[M T W R F S U] def ensure_conforms([], %Clients.Client{}) do - :ok + {:ok, nil} end def ensure_conforms(conditions, %Clients.Client{} = client) when is_list(conditions) do client = Repo.preload(client, :identity) conditions - |> Enum.reduce([], fn condition, violated_properties -> - cond do - conforms?(condition, client) -> violated_properties - condition.property in violated_properties -> violated_properties - true -> [condition.property | violated_properties] + |> Enum.reduce({[], nil}, fn condition, {violated_properties, min_expires_at} -> + if condition.property in violated_properties do + {violated_properties, min_expires_at} + else + case fetch_conformation_expiration(condition, client) do + {:ok, expires_at} -> + {violated_properties, min_expires_at(expires_at, min_expires_at)} + + :error -> + {[condition.property | violated_properties], min_expires_at} + end end end) |> case do - [] -> :ok - violated_properties -> {:error, Enum.reverse(violated_properties)} + {[], expires_at} -> {:ok, expires_at} + {violated_properties, _expires_at} -> {:error, Enum.reverse(violated_properties)} end end - def conforms?( + defp min_expires_at(expires_at, nil), do: expires_at + + defp min_expires_at(expires_at, min_expires_at), + do: Enum.min([expires_at, min_expires_at], DateTime) + + def fetch_conformation_expiration( %Condition{property: :remote_ip_location_region, operator: :is_in, values: values}, %Clients.Client{} = client ) do - client.last_seen_remote_ip_location_region in values + if client.last_seen_remote_ip_location_region in values do + {:ok, nil} + else + :error + end end - def conforms?( + def fetch_conformation_expiration( %Condition{property: :remote_ip_location_region, operator: :is_not_in, values: values}, %Clients.Client{} = client ) do - client.last_seen_remote_ip_location_region not in values + if client.last_seen_remote_ip_location_region in values do + :error + else + {:ok, nil} + end end - def conforms?( + def fetch_conformation_expiration( %Condition{property: :remote_ip, operator: :is_in_cidr, values: values}, %Clients.Client{} = client ) do - Enum.any?(values, fn cidr -> + Enum.reduce_while(values, :error, fn cidr, :error -> {:ok, inet} = Domain.Types.INET.cast(cidr) cidr = %{inet | netmask: inet.netmask || Domain.Types.CIDR.max_netmask(inet)} - Domain.Types.CIDR.contains?(cidr, client.last_seen_remote_ip) + + if Domain.Types.CIDR.contains?(cidr, client.last_seen_remote_ip) do + {:halt, {:ok, nil}} + else + {:cont, :error} + end end) end - def conforms?( + def fetch_conformation_expiration( %Condition{property: :remote_ip, operator: :is_not_in_cidr, values: values}, %Clients.Client{} = client ) do - Enum.all?(values, fn cidr -> + Enum.reduce_while(values, {:ok, nil}, fn cidr, {:ok, nil} -> {:ok, inet} = Domain.Types.INET.cast(cidr) cidr = %{inet | netmask: inet.netmask || Domain.Types.CIDR.max_netmask(inet)} - not Domain.Types.CIDR.contains?(cidr, client.last_seen_remote_ip) + + if Domain.Types.CIDR.contains?(cidr, client.last_seen_remote_ip) do + {:halt, :error} + else + {:cont, {:ok, nil}} + end end) end - def conforms?( + def fetch_conformation_expiration( %Condition{property: :provider_id, operator: :is_in, values: values}, %Clients.Client{} = client ) do client = Repo.preload(client, :identity) - client.identity.provider_id in values + + if client.identity.provider_id in values do + {:ok, nil} + else + :error + end end - def conforms?( + def fetch_conformation_expiration( %Condition{property: :provider_id, operator: :is_not_in, values: values}, %Clients.Client{} = client ) do client = Repo.preload(client, :identity) - client.identity.provider_id not in values + + if client.identity.provider_id in values do + :error + else + {:ok, nil} + end end - def conforms?( + def fetch_conformation_expiration( %Condition{ property: :current_utc_datetime, operator: :is_in_day_of_week_time_ranges, @@ -86,33 +125,75 @@ defmodule Domain.Policies.Condition.Evaluator do }, %Clients.Client{} ) do - datetime_in_day_of_the_week_time_ranges?(DateTime.utc_now(), values) + case find_day_of_the_week_time_range(values, DateTime.utc_now()) do + nil -> :error + expires_at -> {:ok, expires_at} + end end - def datetime_in_day_of_the_week_time_ranges?(datetime, dow_time_ranges) do + def find_day_of_the_week_time_range(dow_time_ranges, datetime) do dow_time_ranges |> parse_days_of_week_time_ranges() |> case do {:ok, dow_time_ranges} -> - Enum.any?(dow_time_ranges, fn {day, time_ranges} -> + dow_time_ranges + |> Enum.find_value(fn {day, time_ranges} -> + time_ranges = merge_joint_time_ranges(time_ranges) datetime_in_time_ranges?(datetime, day, time_ranges) end) {:error, _reason} -> - false + nil end end + @doc false + # Merge ranges, eg. 4-11,11-22 = 4-22 + def merge_joint_time_ranges(time_ranges) do + merged_time_ranges = + Enum.reduce(time_ranges, [], fn {start_time, end_time, timezone}, acc -> + index = + Enum.find_index(acc, fn {acc_start_time, acc_end_time, acc_timezone} -> + acc_timezone == timezone and + (time_in_range?(start_time, acc_start_time, acc_end_time) or + time_in_range?(end_time, acc_start_time, acc_end_time) or + time_in_range?(acc_start_time, start_time, end_time) or + time_in_range?(acc_end_time, start_time, end_time)) + end) + + if index == nil do + [{start_time, end_time, timezone}] ++ acc + else + {{acc_start_time, acc_end_time, _timezone}, acc} = List.pop_at(acc, index) + start_time = Enum.min([start_time, acc_start_time], Time) + end_time = Enum.max([end_time, acc_end_time], Time) + [{start_time, end_time, timezone}] ++ acc + end + end) + |> Enum.reverse() + + if merged_time_ranges == time_ranges do + merged_time_ranges + else + merge_joint_time_ranges(merged_time_ranges) + end + end + + defp time_in_range?(time, range_start, range_end) do + Time.compare(range_start, time) in [:lt, :eq] and + Time.compare(time, range_end) in [:lt, :eq] + end + defp datetime_in_time_ranges?(datetime, day_of_the_week, time_ranges) do - Enum.any?(time_ranges, fn {start_time, end_time, timezone} -> - {:ok, datetime} = DateTime.shift_zone(datetime, timezone, Tzdata.TimeZoneDatabase) + Enum.find_value(time_ranges, fn {start_time, end_time, timezone} -> + datetime = DateTime.shift_zone!(datetime, timezone, Tzdata.TimeZoneDatabase) date = DateTime.to_date(datetime) time = DateTime.to_time(datetime) - if Enum.at(@days_of_week, Date.day_of_week(date) - 1) == day_of_the_week do - Time.compare(start_time, time) != :gt and Time.compare(time, end_time) != :gt - else - false + if Enum.at(@days_of_week, Date.day_of_week(date) - 1) == day_of_the_week and + Time.compare(start_time, time) != :gt and Time.compare(time, end_time) != :gt do + DateTime.new!(date, end_time, timezone, Tzdata.TimeZoneDatabase) + |> DateTime.shift_zone!("UTC", Tzdata.TimeZoneDatabase) end end) end diff --git a/elixir/apps/domain/test/domain/flows_test.exs b/elixir/apps/domain/test/domain/flows_test.exs index d9dcbde78..c900ee60f 100644 --- a/elixir/apps/domain/test/domain/flows_test.exs +++ b/elixir/apps/domain/test/domain/flows_test.exs @@ -172,6 +172,13 @@ defmodule Domain.FlowsTest do actor_group2 = Fixtures.Actors.create_group(account: account) Fixtures.Actors.create_membership(account: account, actor: actor, group: actor_group2) + time = Time.utc_now() + one_hour_ago = Time.add(time, -1, :hour) + one_hour_in_future = Time.add(time, 1, :hour) + + date = Date.utc_today() + day_of_week = Enum.at(~w[M T W R F S U], Date.day_of_week(date) - 1) + Fixtures.Policies.create_policy( account: account, actor_group: actor_group2, @@ -181,6 +188,13 @@ defmodule Domain.FlowsTest do property: :remote_ip_location_region, operator: :is_not_in, values: [client.last_seen_remote_ip_location_region] + }, + %{ + property: :current_utc_datetime, + operator: :is_in_day_of_week_time_ranges, + values: [ + "#{day_of_week}/#{one_hour_ago}-#{one_hour_in_future}/UTC" + ] } ] ) @@ -189,6 +203,7 @@ defmodule Domain.FlowsTest do authorize_flow(client, gateway, resource.id, subject) assert flow.policy_id == policy.id + assert DateTime.diff(flow.expires_at, DateTime.new!(date, one_hour_in_future)) < 5 end test "creates a flow when all conditions for at least one of the policies are satisfied", %{ @@ -223,8 +238,10 @@ defmodule Domain.FlowsTest do ] ) - assert {:ok, _fetched_resource, _flow} = + assert {:ok, _fetched_resource, flow} = authorize_flow(client, gateway, resource.id, subject) + + assert flow.expires_at == subject.expires_at end test "creates a network flow for users", %{ diff --git a/elixir/apps/domain/test/domain/policies/condition/evaluator_test.exs b/elixir/apps/domain/test/domain/policies/condition/evaluator_test.exs index 0be4291ed..c218379df 100644 --- a/elixir/apps/domain/test/domain/policies/condition/evaluator_test.exs +++ b/elixir/apps/domain/test/domain/policies/condition/evaluator_test.exs @@ -5,7 +5,7 @@ defmodule Domain.Policies.Condition.EvaluatorTest do describe "ensure_conforms/2" do test "returns ok when there are no conditions" do client = %Domain.Clients.Client{} - assert ensure_conforms([], client) == :ok + assert ensure_conforms([], client) == {:ok, nil} end test "returns ok when all conditions are met" do @@ -27,7 +27,7 @@ defmodule Domain.Policies.Condition.EvaluatorTest do } ] - assert ensure_conforms(conditions, client) == :ok + assert ensure_conforms(conditions, client) == {:ok, nil} end test "returns error when all conditions are not met" do @@ -77,7 +77,7 @@ defmodule Domain.Policies.Condition.EvaluatorTest do end end - describe "conforms?/2" do + describe "fetch_conformation_expiration/2" do test "when client last seen remote ip location region is in or not in the values" do condition = %Domain.Policies.Condition{ property: :remote_ip_location_region, @@ -88,15 +88,17 @@ defmodule Domain.Policies.Condition.EvaluatorTest do last_seen_remote_ip_location_region: "US" } - assert conforms?(%{condition | operator: :is_in}, client) == true - assert conforms?(%{condition | operator: :is_not_in}, client) == false + assert fetch_conformation_expiration(%{condition | operator: :is_in}, client) == {:ok, nil} + assert fetch_conformation_expiration(%{condition | operator: :is_not_in}, client) == :error client = %Domain.Clients.Client{ last_seen_remote_ip_location_region: "CA" } - assert conforms?(%{condition | operator: :is_in}, client) == false - assert conforms?(%{condition | operator: :is_not_in}, client) == true + assert fetch_conformation_expiration(%{condition | operator: :is_in}, client) == :error + + assert fetch_conformation_expiration(%{condition | operator: :is_not_in}, client) == + {:ok, nil} end test "when client last seen remote ip is in or not in the CIDR values" do @@ -109,15 +111,20 @@ defmodule Domain.Policies.Condition.EvaluatorTest do last_seen_remote_ip: %Postgrex.INET{address: {192, 168, 0, 1}} } - assert conforms?(%{condition | operator: :is_in_cidr}, client) == true - assert conforms?(%{condition | operator: :is_not_in_cidr}, client) == false + assert fetch_conformation_expiration(%{condition | operator: :is_in_cidr}, client) == + {:ok, nil} + + assert fetch_conformation_expiration(%{condition | operator: :is_not_in_cidr}, client) == + :error client = %Domain.Clients.Client{ last_seen_remote_ip: %Postgrex.INET{address: {10, 168, 0, 1}} } - assert conforms?(%{condition | operator: :is_in_cidr}, client) == false - assert conforms?(%{condition | operator: :is_not_in_cidr}, client) == true + assert fetch_conformation_expiration(%{condition | operator: :is_in_cidr}, client) == :error + + assert fetch_conformation_expiration(%{condition | operator: :is_not_in_cidr}, client) == + {:ok, nil} end test "when client last seen remote ip is in or not in the IP values" do @@ -130,15 +137,20 @@ defmodule Domain.Policies.Condition.EvaluatorTest do last_seen_remote_ip: %Postgrex.INET{address: {192, 168, 0, 1}} } - assert conforms?(%{condition | operator: :is_in_cidr}, client) == true - assert conforms?(%{condition | operator: :is_not_in_cidr}, client) == false + assert fetch_conformation_expiration(%{condition | operator: :is_in_cidr}, client) == + {:ok, nil} + + assert fetch_conformation_expiration(%{condition | operator: :is_not_in_cidr}, client) == + :error client = %Domain.Clients.Client{ last_seen_remote_ip: %Postgrex.INET{address: {10, 168, 0, 1}} } - assert conforms?(%{condition | operator: :is_in_cidr}, client) == false - assert conforms?(%{condition | operator: :is_not_in_cidr}, client) == true + assert fetch_conformation_expiration(%{condition | operator: :is_in_cidr}, client) == :error + + assert fetch_conformation_expiration(%{condition | operator: :is_not_in_cidr}, client) == + {:ok, nil} end test "when client identity provider id is in or not in the values" do @@ -153,8 +165,8 @@ defmodule Domain.Policies.Condition.EvaluatorTest do } } - assert conforms?(%{condition | operator: :is_in}, client) == true - assert conforms?(%{condition | operator: :is_not_in}, client) == false + assert fetch_conformation_expiration(%{condition | operator: :is_in}, client) == {:ok, nil} + assert fetch_conformation_expiration(%{condition | operator: :is_not_in}, client) == :error client = %Domain.Clients.Client{ identity: %Domain.Auth.Identity{ @@ -162,12 +174,14 @@ defmodule Domain.Policies.Condition.EvaluatorTest do } } - assert conforms?(%{condition | operator: :is_in}, client) == false - assert conforms?(%{condition | operator: :is_not_in}, client) == true + assert fetch_conformation_expiration(%{condition | operator: :is_in}, client) == :error + + assert fetch_conformation_expiration(%{condition | operator: :is_not_in}, client) == + {:ok, nil} end test "when client current UTC datetime is in the day of the week time ranges" do - # this is tested separately in datetime_in_day_of_the_week_time_ranges?/2 + # this is deeply tested separately in find_day_of_the_week_time_range/2 condition = %Domain.Policies.Condition{ property: :current_utc_datetime, values: [] @@ -175,26 +189,57 @@ defmodule Domain.Policies.Condition.EvaluatorTest do client = %Domain.Clients.Client{} - assert conforms?(%{condition | operator: :is_in_day_of_week_time_ranges}, client) == false + assert fetch_conformation_expiration( + %{condition | operator: :is_in_day_of_week_time_ranges}, + client + ) == :error end end - describe "datetime_in_day_of_the_week_time_ranges?/2" do + describe "find_day_of_the_week_time_range/2" do test "returns true when datetime is in the day of the week time ranges" do # Friday datetime = ~U[2021-01-01 10:00:00Z] + # Exact match dow_time_ranges = ["F/10:00:00-10:00:00/UTC"] - assert datetime_in_day_of_the_week_time_ranges?(datetime, dow_time_ranges) == true - dow_time_ranges = ["F/10:00:00-11:00:00/UTC"] - assert datetime_in_day_of_the_week_time_ranges?(datetime, dow_time_ranges) == true + assert DateTime.compare( + find_day_of_the_week_time_range(dow_time_ranges, datetime), + ~U[2021-01-01 10:00:00Z] + ) == :eq - dow_time_ranges = ["F/09:00:00-10:00:00/UTC"] - assert datetime_in_day_of_the_week_time_ranges?(datetime, dow_time_ranges) == true + # Range start match + dow_time_ranges = ["F/10:00:00-11:00:00,20:00-22:00/UTC"] + assert DateTime.compare( + find_day_of_the_week_time_range(dow_time_ranges, datetime), + ~U[2021-01-01 11:00:00Z] + ) == :eq + + # Range end match + dow_time_ranges = ["F/09:00:00-10:00:00,11-22/UTC"] + + assert DateTime.compare( + find_day_of_the_week_time_range(dow_time_ranges, datetime), + ~U[2021-01-01 10:00:00Z] + ) == :eq + + # Entire day match dow_time_ranges = ["F/true/UTC"] - assert datetime_in_day_of_the_week_time_ranges?(datetime, dow_time_ranges) == true + + assert DateTime.compare( + find_day_of_the_week_time_range(dow_time_ranges, datetime), + ~U[2021-01-01 23:59:59Z] + ) == :eq + + # Finds greatest expiration time + dow_time_ranges = ["F/09:00:00-11:00:00,11-15,14-22/UTC"] + + assert DateTime.compare( + find_day_of_the_week_time_range(dow_time_ranges, datetime), + ~U[2021-01-01 22:00:00Z] + ) == :eq end test "returns false when datetime is not in the day of the week time ranges" do @@ -202,55 +247,209 @@ defmodule Domain.Policies.Condition.EvaluatorTest do datetime = ~U[2021-01-01 10:00:00Z] dow_time_ranges = ["F/09:00:00-09:59:59/UTC"] - assert datetime_in_day_of_the_week_time_ranges?(datetime, dow_time_ranges) == false + assert find_day_of_the_week_time_range(dow_time_ranges, datetime) == nil dow_time_ranges = ["F/10:00:01-11:00:00/UTC"] - assert datetime_in_day_of_the_week_time_ranges?(datetime, dow_time_ranges) == false + assert find_day_of_the_week_time_range(dow_time_ranges, datetime) == nil dow_time_ranges = ["M/09:00:00-11:00:00/UTC"] - assert datetime_in_day_of_the_week_time_ranges?(datetime, dow_time_ranges) == false + assert find_day_of_the_week_time_range(dow_time_ranges, datetime) == nil dow_time_ranges = ["U/true/UTC"] - assert datetime_in_day_of_the_week_time_ranges?(datetime, dow_time_ranges) == false + assert find_day_of_the_week_time_range(dow_time_ranges, datetime) == nil end test "handles different timezones" do - # Friday in UTC, Thursday in US/Pacific (UTC-8) + # 01:00 Friday in UTC, 07:00 Thursday in US/Pacific (UTC-8) datetime = ~U[2021-01-01 01:00:00Z] + # Thursday in US/Pacific ends at 07:59:59 UTC dow_time_ranges = ["R/true/US/Pacific"] - assert datetime_in_day_of_the_week_time_ranges?(datetime, dow_time_ranges) == true + assert DateTime.compare( + find_day_of_the_week_time_range(dow_time_ranges, datetime), + ~U[2021-01-01 07:59:59Z] + ) == :eq + + # Friday in UTC dow_time_ranges = ["F/true/UTC"] - assert datetime_in_day_of_the_week_time_ranges?(datetime, dow_time_ranges) == true + assert DateTime.compare( + find_day_of_the_week_time_range(dow_time_ranges, datetime), + ~U[2021-01-01 23:59:59Z] + ) == :eq + + # 19:00 Thursday in US/Pacific (UTC-8) = 03:00 Friday in UTC dow_time_ranges = ["R/15:00:00-19:00:00/US/Pacific"] - assert datetime_in_day_of_the_week_time_ranges?(datetime, dow_time_ranges) == true + assert DateTime.compare( + find_day_of_the_week_time_range(dow_time_ranges, datetime), + ~U[2021-01-01 03:00:00Z] + ) == :eq + + # given datetime is 07:00 Thursday in US/Pacific (UTC-8), so Friday in UTC should not match dow_time_ranges = ["R/00:00:00-02:00:00/US/Pacific"] - assert datetime_in_day_of_the_week_time_ranges?(datetime, dow_time_ranges) == false + assert find_day_of_the_week_time_range(dow_time_ranges, datetime) == nil + # Poland timezone is UTC+1, given datetime in UTC is 02:00 Friday in Poland dow_time_ranges = ["F/02:00:00-04:00:00/Poland"] - assert datetime_in_day_of_the_week_time_ranges?(datetime, dow_time_ranges) == true + + assert DateTime.compare( + find_day_of_the_week_time_range(dow_time_ranges, datetime), + ~U[2021-01-01 03:00:00Z] + ) == :eq end test "returns false when ranges are invalid" do datetime = ~U[2021-01-01 10:00:00Z] dow_time_ranges = ["F/10:00:00-09:59:59/UTC"] - assert datetime_in_day_of_the_week_time_ranges?(datetime, dow_time_ranges) == false + assert find_day_of_the_week_time_range(dow_time_ranges, datetime) == nil dow_time_ranges = ["F/11:00:00-12:00:00-/US/Pacific"] - assert datetime_in_day_of_the_week_time_ranges?(datetime, dow_time_ranges) == false + assert find_day_of_the_week_time_range(dow_time_ranges, datetime) == nil dow_time_ranges = ["F/false/UTC"] - assert datetime_in_day_of_the_week_time_ranges?(datetime, dow_time_ranges) == false + assert find_day_of_the_week_time_range(dow_time_ranges, datetime) == nil dow_time_ranges = ["F/10-11"] - assert datetime_in_day_of_the_week_time_ranges?(datetime, dow_time_ranges) == false + assert find_day_of_the_week_time_range(dow_time_ranges, datetime) == nil dow_time_ranges = ["F/10-11/"] - assert datetime_in_day_of_the_week_time_ranges?(datetime, dow_time_ranges) == false + assert find_day_of_the_week_time_range(dow_time_ranges, datetime) == nil + end + end + + describe "merge_joint_time_ranges/1" do + test "does nothing on empty time ranges" do + time_ranges = [] + assert merge_joint_time_ranges(time_ranges) == time_ranges + end + + test "does nothing on single time range" do + time_ranges = [{~T[10:00:00], ~T[11:00:00], "UTC"}] + assert merge_joint_time_ranges(time_ranges) == time_ranges + end + + test "does not merge overlapping time ranges that do not overlap" do + time_ranges = [ + {~T[10:00:00], ~T[11:00:00], "UTC"}, + {~T[11:00:01], ~T[12:00:00], "UTC"} + ] + + assert merge_joint_time_ranges(time_ranges) == time_ranges + + time_ranges = [ + {~T[09:00:00], ~T[10:00:00], "UTC"}, + {~T[11:00:00], ~T[12:00:00], "UTC"}, + {~T[10:00:01], ~T[10:00:02], "UTC"} + ] + + assert merge_joint_time_ranges(time_ranges) == time_ranges + end + + test "does not merge overlapping time ranges that have different timezones" do + time_ranges = [ + {~T[10:00:00], ~T[11:00:00], "UTC"}, + {~T[10:30:00], ~T[12:00:00], "PDT"} + ] + + assert merge_joint_time_ranges(time_ranges) == time_ranges + end + + test "merges overlapping time ranges" do + time_ranges = [ + {~T[10:00:00], ~T[11:00:00], "UTC"}, + {~T[10:30:00], ~T[12:00:00], "UTC"} + ] + + assert merge_joint_time_ranges(time_ranges) == [ + {~T[10:00:00], ~T[12:00:00], "UTC"} + ] + + time_ranges = [ + {~T[10:00:00], ~T[11:00:00], "UTC"}, + {~T[11:00:00], ~T[12:00:00], "UTC"} + ] + + assert merge_joint_time_ranges(time_ranges) == [ + {~T[10:00:00], ~T[12:00:00], "UTC"} + ] + + time_ranges = [ + {~T[10:00:00], ~T[11:00:00], "UTC"}, + {~T[09:00:00], ~T[10:00:00], "UTC"} + ] + + assert merge_joint_time_ranges(time_ranges) == [ + {~T[09:00:00], ~T[11:00:00], "UTC"} + ] + + time_ranges = [ + {~T[10:00:00], ~T[11:00:00], "UTC"}, + {~T[10:00:00], ~T[12:00:00], "UTC"} + ] + + assert merge_joint_time_ranges(time_ranges) == [ + {~T[10:00:00], ~T[12:00:00], "UTC"} + ] + + time_ranges = [ + {~T[10:00:00], ~T[11:00:00], "UTC"}, + {~T[09:00:00], ~T[12:00:00], "UTC"} + ] + + assert merge_joint_time_ranges(time_ranges) == [ + {~T[09:00:00], ~T[12:00:00], "UTC"} + ] + + time_ranges = [ + {~T[09:00:00], ~T[12:00:00], "UTC"}, + {~T[10:00:00], ~T[11:00:00], "UTC"} + ] + + assert merge_joint_time_ranges(time_ranges) == [ + {~T[09:00:00], ~T[12:00:00], "UTC"} + ] + end + + test "merges multiple overlapping time ranges" do + time_ranges = [ + {~T[09:00:00], ~T[10:00:00], "UTC"}, + {~T[11:00:00], ~T[12:00:00], "UTC"}, + {~T[10:00:00], ~T[11:00:00], "UTC"} + ] + + assert merge_joint_time_ranges(time_ranges) == [ + {~T[09:00:00], ~T[12:00:00], "UTC"} + ] + + time_ranges = [ + {~T[09:00:00], ~T[12:00:00], "UTC"}, + {~T[11:00:00], ~T[12:00:00], "UTC"}, + {~T[10:00:00], ~T[11:00:00], "UTC"}, + {~T[09:00:00], ~T[10:00:00], "UTC"}, + {~T[01:00:00], ~T[10:00:00], "UTC"} + ] + + assert merge_joint_time_ranges(time_ranges) == [ + {~T[01:00:00], ~T[12:00:00], "UTC"} + ] + end + + test "merges two sets of overlapping time ranges" do + time_ranges = [ + {~T[09:00:00], ~T[12:00:00], "UTC"}, + {~T[11:00:00], ~T[13:00:00], "UTC"}, + {~T[10:00:00], ~T[11:00:00], "UTC"}, + {~T[02:00:00], ~T[05:00:00], "UTC"}, + {~T[01:00:00], ~T[08:00:00], "UTC"} + ] + + assert merge_joint_time_ranges(time_ranges) == [ + {~T[09:00:00], ~T[13:00:00], "UTC"}, + {~T[01:00:00], ~T[08:00:00], "UTC"} + ] end end diff --git a/elixir/apps/domain/test/domain/policies_test.exs b/elixir/apps/domain/test/domain/policies_test.exs index fbd17fc2a..63780e9e6 100644 --- a/elixir/apps/domain/test/domain/policies_test.exs +++ b/elixir/apps/domain/test/domain/policies_test.exs @@ -979,7 +979,7 @@ defmodule Domain.PoliciesTest do ] } - assert ensure_client_conforms_policy_conditions(client, policy) == :ok + assert ensure_client_conforms_policy_conditions(client, policy) == {:ok, nil} end test "returns error when client conforms to policy conditions", %{} do diff --git a/elixir/apps/web/lib/web/live/policies/components.ex b/elixir/apps/web/lib/web/live/policies/components.ex index 8a1f823af..955b17ad1 100644 --- a/elixir/apps/web/lib/web/live/policies/components.ex +++ b/elixir/apps/web/lib/web/live/policies/components.ex @@ -256,6 +256,11 @@ defmodule Web.Policies.Components do providers={@providers} disabled={@policy_conditions_enabled? == false} /> + <.current_utc_datetime_condition_form + form={@form} + timezone={@timezone} + disabled={@policy_conditions_enabled? == false} + /> """ @@ -518,95 +523,93 @@ defmodule Web.Policies.Components do """ end - # TODO: Uncomment this once policy time conditions are finished - # defp current_utc_datetime_condition_form(assigns) do - # assigns = assign_new(assigns, :days_of_week, fn -> @days_of_week end) + defp current_utc_datetime_condition_form(assigns) do + assigns = assign_new(assigns, :days_of_week, fn -> @days_of_week end) - # ~H""" - #
- # <% condition_form = find_condition_form(@form[:conditions], :current_utc_datetime) %> + ~H""" +
+ <% condition_form = find_condition_form(@form[:conditions], :current_utc_datetime) %> - # <.input - # type="hidden" - # field={condition_form[:property]} - # name="policy[conditions][current_utc_datetime][property]" - # id="policy_conditions_current_utc_datetime_property" - # value="current_utc_datetime" - # /> + <.input + type="hidden" + field={condition_form[:property]} + name="policy[conditions][current_utc_datetime][property]" + id="policy_conditions_current_utc_datetime_property" + value="current_utc_datetime" + /> - # <.input - # type="hidden" - # name="policy[conditions][current_utc_datetime][operator]" - # id="policy_conditions_current_utc_datetime_operator" - # field={condition_form[:operator]} - # value={:is_in_day_of_week_time_ranges} - # /> + <.input + type="hidden" + name="policy[conditions][current_utc_datetime][operator]" + id="policy_conditions_current_utc_datetime_operator" + field={condition_form[:operator]} + value={:is_in_day_of_week_time_ranges} + /> - #
JS.toggle_class("bg-neutral-50") - # |> JS.toggle_class("hero-chevron-down", - # to: "#policy_conditions_current_utc_datetime_chevron" - # ) - # |> JS.toggle_class("hero-chevron-up", - # to: "#policy_conditions_current_utc_datetime_chevron" - # ) - # } - # > - # - # - # <.icon name="hero-clock" class="w-5 h-5 mr-2" /> Current time - # - # - # <.icon - # id="policy_conditions_current_utc_datetime_chevron" - # name="hero-chevron-down" - # class="w-5 h-5" - # /> - # - # - #
+
JS.toggle_class("bg-neutral-50") + |> JS.toggle_class("hero-chevron-down", + to: "#policy_conditions_current_utc_datetime_chevron" + ) + |> JS.toggle_class("hero-chevron-up", + to: "#policy_conditions_current_utc_datetime_chevron" + ) + } + > + + + <.icon name="hero-clock" class="w-5 h-5 mr-2" /> Current time + + + <.icon + id="policy_conditions_current_utc_datetime_chevron" + name="hero-chevron-down" + class="w-5 h-5" + /> + + +
- #
- #

- # Restrict access based on the current time of the day in 24hr format. - # Multiple time ranges per day are supported. - #

- #
- # <.input - # type="select" - # label="Timezone" - # name="policy[conditions][current_utc_datetime][timezone]" - # id="policy_conditions_current_utc_datetime_timezone" - # field={condition_form[:timezone]} - # options={Tzdata.zone_list()} - # disabled={@disabled} - # value={condition_form[:timezone].value || @timezone} - # /> +
+

+ Restrict access based on the current time of the day in 24hr format. Multiple time ranges per day are supported. +

+
+ <.input + type="select" + label="Timezone" + name="policy[conditions][current_utc_datetime][timezone]" + id="policy_conditions_current_utc_datetime_timezone" + field={condition_form[:timezone]} + options={Tzdata.zone_list()} + disabled={@disabled} + value={condition_form[:timezone].value || @timezone} + /> - #
- # <.current_utc_datetime_condition_day_input - # :for={{code, _name} <- @days_of_week} - # disabled={@disabled} - # condition_form={condition_form} - # day={code} - # /> - #
- #
- #
- #
- # """ - # end +
+ <.current_utc_datetime_condition_day_input + :for={{code, _name} <- @days_of_week} + disabled={@disabled} + condition_form={condition_form} + day={code} + /> +
+ + +
+ """ + end defp find_condition_form(form_field, property) do condition_form = @@ -620,32 +623,30 @@ defmodule Web.Policies.Components do condition_form || to_form(%{}) end - # TODO: Uncomment this once policy time conditions are finished - # defp current_utc_datetime_condition_day_input(assigns) do - # ~H""" - # <.input - # type="text" - # label={day_of_week_name(@day)} - # field={@condition_form[:values]} - # name={"policy[conditions][current_utc_datetime][values][#{@day}]"} - # id={"policy_conditions_current_utc_datetime_values_#{@day}"} - # placeholder="E.g. 9:00-12:00, 13:00-17:00" - # value={get_datetime_range_for_day_of_week(@day, @condition_form[:values])} - # disabled={@disabled} - # value_index={day_of_week_index(@day)} - # /> - # """ - # end + defp current_utc_datetime_condition_day_input(assigns) do + ~H""" + <.input + type="text" + label={day_of_week_name(@day)} + field={@condition_form[:values]} + name={"policy[conditions][current_utc_datetime][values][#{@day}]"} + id={"policy_conditions_current_utc_datetime_values_#{@day}"} + placeholder="E.g. 9:00-12:00, 13:00-17:00" + value={get_datetime_range_for_day_of_week(@day, @condition_form[:values])} + disabled={@disabled} + value_index={day_of_week_index(@day)} + /> + """ + end - # TODO: Uncomment this once policy time conditions are finished - # defp get_datetime_range_for_day_of_week(day, form_field) do - # Enum.find_value(form_field.value || [], fn dow_time_ranges -> - # case String.split(dow_time_ranges, "/", parts: 3) do - # [^day, ranges, _timezone] -> ranges - # _other -> false - # end - # end) - # end + defp get_datetime_range_for_day_of_week(day, form_field) do + Enum.find_value(form_field.value || [], fn dow_time_ranges -> + case String.split(dow_time_ranges, "/", parts: 3) do + [^day, ranges, _timezone] -> ranges + _other -> false + end + end) + end defp condition_operator_options(property) do Domain.Policies.Condition.Changeset.valid_operators_for_property(property) diff --git a/elixir/apps/web/test/web/live/policies/new_test.exs b/elixir/apps/web/test/web/live/policies/new_test.exs index 7102f5fea..f98246aec 100644 --- a/elixir/apps/web/test/web/live/policies/new_test.exs +++ b/elixir/apps/web/test/web/live/policies/new_test.exs @@ -60,6 +60,16 @@ defmodule Web.Live.Policies.NewTest do assert find_inputs(form) == [ "policy[actor_group_id]", + "policy[conditions][current_utc_datetime][operator]", + "policy[conditions][current_utc_datetime][property]", + "policy[conditions][current_utc_datetime][timezone]", + "policy[conditions][current_utc_datetime][values][F]", + "policy[conditions][current_utc_datetime][values][M]", + "policy[conditions][current_utc_datetime][values][R]", + "policy[conditions][current_utc_datetime][values][S]", + "policy[conditions][current_utc_datetime][values][T]", + "policy[conditions][current_utc_datetime][values][U]", + "policy[conditions][current_utc_datetime][values][W]", "policy[conditions][provider_id][operator]", "policy[conditions][provider_id][property]", "policy[conditions][provider_id][values][]", @@ -89,6 +99,16 @@ defmodule Web.Live.Policies.NewTest do assert find_inputs(form) == [ "policy[actor_group_id]", + "policy[conditions][current_utc_datetime][operator]", + "policy[conditions][current_utc_datetime][property]", + "policy[conditions][current_utc_datetime][timezone]", + "policy[conditions][current_utc_datetime][values][F]", + "policy[conditions][current_utc_datetime][values][M]", + "policy[conditions][current_utc_datetime][values][R]", + "policy[conditions][current_utc_datetime][values][S]", + "policy[conditions][current_utc_datetime][values][T]", + "policy[conditions][current_utc_datetime][values][U]", + "policy[conditions][current_utc_datetime][values][W]", "policy[conditions][provider_id][operator]", "policy[conditions][provider_id][property]", "policy[conditions][provider_id][values][]", @@ -129,6 +149,16 @@ defmodule Web.Live.Policies.NewTest do assert find_inputs(form) == [ "policy[actor_group_id]", + "policy[conditions][current_utc_datetime][operator]", + "policy[conditions][current_utc_datetime][property]", + "policy[conditions][current_utc_datetime][timezone]", + "policy[conditions][current_utc_datetime][values][F]", + "policy[conditions][current_utc_datetime][values][M]", + "policy[conditions][current_utc_datetime][values][R]", + "policy[conditions][current_utc_datetime][values][S]", + "policy[conditions][current_utc_datetime][values][T]", + "policy[conditions][current_utc_datetime][values][U]", + "policy[conditions][current_utc_datetime][values][W]", "policy[conditions][provider_id][operator]", "policy[conditions][provider_id][property]", "policy[conditions][provider_id][values][]", @@ -254,6 +284,20 @@ defmodule Web.Live.Policies.NewTest do actor_group_id: group.id, resource_id: resource.id, conditions: %{ + current_utc_datetime: %{ + property: "current_utc_datetime", + operator: "is_in_day_of_week_time_ranges", + timezone: "US/Pacific", + values: %{ + M: "true", + T: "", + W: "true", + R: "", + F: "", + S: "10:00:00-15:00:00", + U: "23:00:00-23:59:59" + } + }, provider_id: %{ property: "provider_id", operator: "is_in", @@ -285,6 +329,19 @@ defmodule Web.Live.Policies.NewTest do assert policy.resource_id == resource.id assert policy.conditions == [ + %Domain.Policies.Condition{ + property: :current_utc_datetime, + operator: :is_in_day_of_week_time_ranges, + values: [ + "M/true/US/Pacific", + "T//US/Pacific", + "W/true/US/Pacific", + "R//US/Pacific", + "F//US/Pacific", + "S/10:00:00-15:00:00/US/Pacific", + "U/23:00:00-23:59:59/US/Pacific" + ] + }, %Domain.Policies.Condition{ property: :provider_id, operator: :is_in, diff --git a/website/src/app/kb/deploy/policies/readme.mdx b/website/src/app/kb/deploy/policies/readme.mdx index 9208fe4f4..8366c57d3 100644 --- a/website/src/app/kb/deploy/policies/readme.mdx +++ b/website/src/app/kb/deploy/policies/readme.mdx @@ -69,6 +69,24 @@ Restrict access to a specific IP address or range of Client IP addresses. Restrict access based on the authentication provider that was used to authenticate the Client. +### Time of day + +Time of day + +Restrict access to certain time windows throughout the week based on the 24hr +time and specified time zone. + +The time zone determines the offset used when determining whether to allow +access for a particular Client. For example, if you specify a time window of +`08:00-17:00` and time zone of `Eastern`, Clients in the `Pacific` timezone 3 +hours behind will be allowed access from `05:00-14:00` Eastern time. + Next: Install Clients