feat(portal): Time based policies (#6115)

Flows authorized by time-based policies will now expire at the latest
time permitted by the policy.
This commit is contained in:
Andrew Dryga
2024-08-02 01:49:44 -06:00
committed by GitHub
parent abfd378fe9
commit 63de0efb73
9 changed files with 567 additions and 194 deletions

View File

@@ -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

View File

@@ -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}}

View File

@@ -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

View File

@@ -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", %{

View File

@@ -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

View File

@@ -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

View File

@@ -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}
/>
</div>
</fieldset>
"""
@@ -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"""
# <fieldset class="mb-2">
# <% condition_form = find_condition_form(@form[:conditions], :current_utc_datetime) %>
~H"""
<fieldset class="mb-2">
<% 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}
/>
# <div
# class="hover:bg-neutral-100 cursor-pointer border border-neutral-200 shadow-b rounded-t px-4 py-2"
# phx-click={
# JS.toggle_class("hidden",
# to: "#policy_conditions_current_utc_datetime_condition"
# )
# |> 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"
# )
# }
# >
# <legend class="flex justify-between items-center text-neutral-700">
# <span class="flex items-center">
# <.icon name="hero-clock" class="w-5 h-5 mr-2" /> Current time
# </span>
# <span class="shadow bg-white w-6 h-6 flex items-center justify-center rounded-full">
# <.icon
# id="policy_conditions_current_utc_datetime_chevron"
# name="hero-chevron-down"
# class="w-5 h-5"
# />
# </span>
# </legend>
# </div>
<div
class="hover:bg-neutral-100 cursor-pointer border border-neutral-200 shadow-b rounded-t px-4 py-2"
phx-click={
JS.toggle_class("hidden",
to: "#policy_conditions_current_utc_datetime_condition"
)
|> 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"
)
}
>
<legend class="flex justify-between items-center text-neutral-700">
<span class="flex items-center">
<.icon name="hero-clock" class="w-5 h-5 mr-2" /> Current time
</span>
<span class="shadow bg-white w-6 h-6 flex items-center justify-center rounded-full">
<.icon
id="policy_conditions_current_utc_datetime_chevron"
name="hero-chevron-down"
class="w-5 h-5"
/>
</span>
</legend>
</div>
# <div
# id="policy_conditions_current_utc_datetime_condition"
# class={[
# "p-4 border-neutral-200 border-l border-r border-b rounded-b",
# condition_values_empty?(condition_form) && "hidden"
# ]}
# >
# <p class="text-sm text-neutral-500 mb-2">
# Restrict access based on the current time of the day in 24hr format.
# Multiple time ranges per day are supported.
# </p>
# <div class="space-y-2">
# <.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}
# />
<div
id="policy_conditions_current_utc_datetime_condition"
class={[
"p-4 border-neutral-200 border-l border-r border-b rounded-b",
condition_values_empty?(condition_form) && "hidden"
]}
>
<p class="text-sm text-neutral-500 mb-2">
Restrict access based on the current time of the day in 24hr format. Multiple time ranges per day are supported.
</p>
<div class="space-y-2">
<.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}
/>
# <div class="space-y-2">
# <.current_utc_datetime_condition_day_input
# :for={{code, _name} <- @days_of_week}
# disabled={@disabled}
# condition_form={condition_form}
# day={code}
# />
# </div>
# </div>
# </div>
# </fieldset>
# """
# end
<div class="space-y-2">
<.current_utc_datetime_condition_day_input
:for={{code, _name} <- @days_of_week}
disabled={@disabled}
condition_form={condition_form}
day={code}
/>
</div>
</div>
</div>
</fieldset>
"""
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)

View File

@@ -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,

View File

@@ -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
<Image
src="/images/kb/deploy/policies/client_tod.png"
alt="Time of day"
width={600}
height={600}
className="mx-auto shadow rounded"
/>
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.
<NextStep href="/kb/deploy/clients">Next: Install Clients</NextStep>
<SupportOptions />