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"""
- #