feat(portal): Add Policy conditions (#5144)

Now policies can have additional conditions based on Client location
(country or IP range), IdP provider used for sign in or the current time
of the day at a given timezone. This covers use cases where employees
can access the production system only from certain countries (states can
be added later) or when contractors can only access internal tools
during working hours.

Closes https://github.com/firezone/firezone/issues/4743
Closes #4742
Closes #4741
Closes #4740


<img width="1728" alt="Screenshot 2024-05-31 at 13 50 53"
src="https://github.com/firezone/firezone/assets/1877644/55f509f2-0f49-4edb-8c03-7a5a6d884ccc">
<img width="1728" alt="Screenshot 2024-05-31 at 13 50 56"
src="https://github.com/firezone/firezone/assets/1877644/756bb03f-4024-4978-ac85-6daa918ae037">
<img width="1728" alt="Screenshot 2024-05-31 at 13 51 01"
src="https://github.com/firezone/firezone/assets/1877644/cf159a86-077f-4ada-9952-9e8d399d0dc1">
<img width="1728" alt="Screenshot 2024-05-31 at 13 51 03"
src="https://github.com/firezone/firezone/assets/1877644/c070719e-2d4b-41bd-ad03-430baf2dbe9b">
<img width="676" alt="Screenshot 2024-05-31 at 14 56 06"
src="https://github.com/firezone/firezone/assets/1877644/435a4951-479d-4371-99c4-29a055348175">
This commit is contained in:
Andrew Dryga
2024-06-09 12:46:35 -06:00
committed by GitHub
parent 74e9b5c8a6
commit 650d7d7998
45 changed files with 2540 additions and 400 deletions

View File

@@ -97,6 +97,7 @@ services:
STATIC_SEEDS: "true"
# Feature flags
FLOW_ACTIVITIES_ENABLED: "true"
FEATURE_POLICY_CONDITIONS_ENABLED: "true"
MULTI_SITE_RESOURCES_ENABLED: "true"
SELF_HOSTED_RELAYS_ENABLED: "true"
IDP_SYNC_ENABLED: "true"
@@ -165,6 +166,7 @@ services:
STATIC_SEEDS: "true"
# Feature flags
FLOW_ACTIVITIES_ENABLED: "true"
FEATURE_POLICY_CONDITIONS_ENABLED: "true"
MULTI_SITE_RESOURCES_ENABLED: "true"
SELF_HOSTED_RELAYS_ENABLED: "true"
IDP_SYNC_ENABLED: "true"
@@ -227,6 +229,7 @@ services:
STATIC_SEEDS: "true"
# Feature flags
FLOW_ACTIVITIES_ENABLED: "true"
FEATURE_POLICY_CONDITIONS_ENABLED: "true"
MULTI_SITE_RESOURCES_ENABLED: "true"
SELF_HOSTED_RELAYS_ENABLED: "true"
IDP_SYNC_ENABLED: "true"
@@ -295,6 +298,7 @@ services:
STATIC_SEEDS: "true"
# Feature flags
FLOW_ACTIVITIES_ENABLED: "true"
FEATURE_POLICY_CONDITIONS_ENABLED: "true"
MULTI_SITE_RESOURCES_ENABLED: "true"
SELF_HOSTED_RELAYS_ENABLED: "true"
IDP_SYNC_ENABLED: "true"

View File

@@ -465,6 +465,12 @@ defmodule API.Client.Channel do
OpenTelemetry.Tracer.set_status(:error, "not_found")
{:reply, {:error, %{reason: :not_found}}, socket}
{:error, {:forbidden, violated_properties: violated_properties}} ->
OpenTelemetry.Tracer.set_status(:error, "forbidden")
{:reply, {:error, %{reason: :forbidden, violated_properties: violated_properties}},
socket}
false ->
OpenTelemetry.Tracer.set_status(:error, "offline")
{:reply, {:error, %{reason: :offline}}, socket}
@@ -521,6 +527,12 @@ defmodule API.Client.Channel do
OpenTelemetry.Tracer.set_status(:error, "not_found")
{:reply, {:error, %{reason: :not_found}}, socket}
{:error, {:forbidden, violated_properties: violated_properties}} ->
OpenTelemetry.Tracer.set_status(:error, "forbidden")
{:reply, {:error, %{reason: :forbidden, violated_properties: violated_properties}},
socket}
false ->
OpenTelemetry.Tracer.set_status(:error, "offline")
{:reply, {:error, %{reason: :offline}}, socket}

View File

@@ -936,6 +936,49 @@ defmodule API.Client.ChannelTest do
assert_reply ref, :error, %{reason: :offline}
end
test "returns error when flow is not authorized due to failing conditions", %{
account: account,
client: client,
actor_group: actor_group,
gateway_group: gateway_group,
gateway: gateway,
socket: socket
} do
resource =
Fixtures.Resources.create_resource(
account: account,
connections: [%{gateway_group_id: gateway_group.id}]
)
Fixtures.Policies.create_policy(
account: account,
actor_group: actor_group,
resource: resource,
conditions: [
%{
property: :remote_ip_location_region,
operator: :is_not_in,
values: [client.last_seen_remote_ip_location_region]
}
]
)
attrs = %{
"resource_id" => resource.id,
"gateway_id" => gateway.id,
"payload" => "DNS_Q"
}
:ok = Domain.Gateways.connect_gateway(gateway)
ref = push(socket, "reuse_connection", attrs)
assert_reply ref, :error, %{
reason: :forbidden,
violated_properties: [:remote_ip_location_region]
}
end
test "returns error when client has no policy allowing access to resource", %{
account: account,
socket: socket
@@ -1117,6 +1160,50 @@ defmodule API.Client.ChannelTest do
assert_reply ref, :error, %{reason: :not_found}
end
test "returns error when flow is not authorized due to failing conditions", %{
account: account,
client: client,
actor_group: actor_group,
gateway_group: gateway_group,
gateway: gateway,
socket: socket
} do
resource =
Fixtures.Resources.create_resource(
account: account,
connections: [%{gateway_group_id: gateway_group.id}]
)
Fixtures.Policies.create_policy(
account: account,
actor_group: actor_group,
resource: resource,
conditions: [
%{
property: :remote_ip_location_region,
operator: :is_not_in,
values: [client.last_seen_remote_ip_location_region]
}
]
)
attrs = %{
"resource_id" => resource.id,
"gateway_id" => gateway.id,
"client_payload" => "RTC_SD",
"client_preshared_key" => "PSK"
}
:ok = Domain.Gateways.connect_gateway(gateway)
ref = push(socket, "request_connection", attrs)
assert_reply ref, :error, %{
reason: :forbidden,
violated_properties: [:remote_ip_location_region]
}
end
test "returns error when gateway is offline", %{
dns_resource: resource,
gateway: gateway,

View File

@@ -4,6 +4,7 @@ defmodule Domain.Accounts.Features do
@primary_key false
embedded_schema do
field :flow_activities, :boolean
field :policy_conditions, :boolean
field :multi_site_resources, :boolean
field :traffic_filters, :boolean
field :self_hosted_relays, :boolean

View File

@@ -2,7 +2,15 @@ defmodule Domain.Accounts.Features.Changeset do
use Domain, :changeset
alias Domain.Accounts.Features
@fields ~w[flow_activities multi_site_resources traffic_filters self_hosted_relays idp_sync rest_api]a
@fields ~w[
flow_activities
policy_conditions
multi_site_resources
traffic_filters
self_hosted_relays
idp_sync
rest_api
]a
def changeset(features \\ %Features{}, attrs) do
features

View File

@@ -646,6 +646,11 @@ defmodule Domain.Config.Definitions do
"""
defconfig(:feature_self_hosted_relays_enabled, :boolean, default: false)
@doc """
Boolean flag to turn Policy Conditions functionality on/off for all accounts.
"""
defconfig(:feature_policy_conditions_enabled, :boolean, default: false)
@doc """
Boolean flag to turn Multi-Site resources functionality on/off for all accounts.
"""

View File

@@ -5,14 +5,12 @@ defmodule Domain.Flows do
require Ecto.Query
require Logger
def authorize_flow(client, gateway, id, subject, opts \\ [])
def authorize_flow(
%Clients.Client{
id: client_id,
account_id: account_id,
actor_id: actor_id
},
} = client,
%Gateways.Gateway{
id: gateway_id,
last_seen_remote_ip: gateway_remote_ip,
@@ -29,15 +27,16 @@ defmodule Domain.Flows do
user_agent: client_user_agent
}
} = subject,
opts
opts \\ []
) 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) do
Resources.fetch_and_authorize_resource_by_id(resource_id, subject, opts),
{:ok, policy} <- fetch_conforming_policy(resource, client) do
flow =
Flow.Changeset.create(%{
token_id: token_id,
policy_id: resource.authorized_by_policy.id,
policy_id: policy.id,
client_id: client_id,
gateway_id: gateway_id,
resource_id: resource.id,
@@ -53,27 +52,23 @@ defmodule Domain.Flows do
end
end
def authorize_flow(client, gateway, id, subject, _opts) do
Logger.error("authorize_flow/4 called with invalid arguments",
id: id,
client: %{
id: client.id,
account_id: client.account_id,
actor_id: client.actor_id,
identity_id: client.identity_id
},
gateway: %{
id: gateway.id,
account_id: gateway.account_id
},
subject: %{
account: %{id: subject.account.id, slug: subject.account.slug},
actor: %{id: subject.actor.id, type: subject.actor.type},
identity: %{id: subject.identity.id}
}
)
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}}
{:error, :internal_error}
{:error, {:forbidden, violated_properties: violated_properties}} ->
{:cont, {:error, violated_properties ++ acc}}
end
end)
|> case do
{:error, violated_properties} ->
{:error, {:forbidden, violated_properties: violated_properties}}
{:ok, policy} ->
{:ok, policy}
end
end
def fetch_flow_by_id(id, %Auth.Subject{} = subject, opts \\ []) do

View File

@@ -1,259 +1,268 @@
defmodule Domain.Geo do
@radius_of_earth_km 6371.0
@coordinates_by_code %{
"AF" => {33, 65},
"AX" => {60.116667, 19.9},
"AL" => {41, 20},
"DZ" => {28, 3},
"AS" => {-14.33333333, -170},
"AD" => {42.5, 1.5},
"AO" => {-12.5, 18.5},
"AI" => {18.25, -63.16666666},
"AQ" => {-74.65, 4.48},
"AG" => {17.05, -61.8},
"AR" => {-34, -64},
"AM" => {40, 45},
"AW" => {12.5, -69.96666666},
"AU" => {-27, 133},
"AT" => {47.33333333, 13.33333333},
"AZ" => {40.5, 47.5},
"BS" => {24.25, -76},
"BH" => {26, 50.55},
"BD" => {24, 90},
"BB" => {13.16666666, -59.53333333},
"BY" => {53, 28},
"BE" => {50.83333333, 4},
"BZ" => {17.25, -88.75},
"BJ" => {9.5, 2.25},
"BM" => {32.33333333, -64.75},
"BT" => {27.5, 90.5},
"BO" => {-17, -65},
"BQ" => {12.15, -68.266667},
"BA" => {44, 18},
"BW" => {-22, 24},
"BV" => {-54.43333333, 3.4},
"BR" => {-10, -55},
"IO" => {-6, 71.5},
"VG" => {18.431383, -64.62305},
"VI" => {18.34, -64.93},
"BN" => {4.5, 114.66666666},
"BG" => {43, 25},
"BF" => {13, -2},
"BI" => {-3.5, 30},
"KH" => {13, 105},
"CM" => {6, 12},
"CA" => {60, -95},
"CV" => {16, -24},
"KY" => {19.5, -80.5},
"CF" => {7, 21},
"TD" => {15, 19},
"CL" => {-30, -71},
"CN" => {35, 105},
"CX" => {-10.5, 105.66666666},
"CC" => {-12.5, 96.83333333},
"CO" => {4, -72},
"KM" => {-12.16666666, 44.25},
"CG" => {-1, 15},
"CD" => {0, 25},
"CK" => {-21.23333333, -159.76666666},
"CR" => {10, -84},
"HR" => {45.16666666, 15.5},
"CU" => {21.5, -80},
"CW" => {12.116667, -68.933333},
"CY" => {35, 33},
"CZ" => {49.75, 15.5},
"DK" => {56, 10},
"DJ" => {11.5, 43},
"DM" => {15.41666666, -61.33333333},
"DO" => {19, -70.66666666},
"EC" => {-2, -77.5},
"EG" => {27, 30},
"SV" => {13.83333333, -88.91666666},
"GQ" => {2, 10},
"ER" => {15, 39},
"EE" => {59, 26},
"ET" => {8, 38},
"FK" => {-51.75, -59},
"FO" => {62, -7},
"FJ" => {-18, 175},
"FI" => {64, 26},
"FR" => {46, 2},
"GF" => {4, -53},
"PF" => {-15, -140},
"TF" => {-49.25, 69.167},
"GA" => {-1, 11.75},
"GM" => {13.46666666, -16.56666666},
"GE" => {42, 43.5},
"DE" => {51, 9},
"GH" => {8, -2},
"GI" => {36.13333333, -5.35},
"GR" => {39, 22},
"GL" => {72, -40},
"GD" => {12.11666666, -61.66666666},
"GP" => {16.25, -61.583333},
"GU" => {13.46666666, 144.78333333},
"GT" => {15.5, -90.25},
"GG" => {49.46666666, -2.58333333},
"GN" => {11, -10},
"GW" => {12, -15},
"GY" => {5, -59},
"HT" => {19, -72.41666666},
"HM" => {-53.1, 72.51666666},
"VA" => {41.9, 12.45},
"HN" => {15, -86.5},
"HU" => {47, 20},
"HK" => {22.25, 114.16666666},
"IS" => {65, -18},
"IN" => {20, 77},
"ID" => {-5, 120},
"CI" => {8, -5},
"IR" => {32, 53},
"IQ" => {33, 44},
"IE" => {53, -8},
"IM" => {54.25, -4.5},
"IL" => {31.5, 34.75},
"IT" => {42.83333333, 12.83333333},
"JM" => {18.25, -77.5},
"JP" => {36, 138},
"JE" => {49.25, -2.16666666},
"JO" => {31, 36},
"KZ" => {48, 68},
"KE" => {1, 38},
"KI" => {1.41666666, 173},
"KW" => {29.5, 45.75},
"KG" => {41, 75},
"LA" => {18, 105},
"LV" => {57, 25},
"LB" => {33.83333333, 35.83333333},
"LS" => {-29.5, 28.5},
"LR" => {6.5, -9.5},
"LY" => {25, 17},
"LI" => {47.26666666, 9.53333333},
"LT" => {56, 24},
"LU" => {49.75, 6.16666666},
"MO" => {22.16666666, 113.55},
"MK" => {41.83333333, 22},
"MG" => {-20, 47},
"MW" => {-13.5, 34},
"MY" => {2.5, 112.5},
"MV" => {3.25, 73},
"ML" => {17, -4},
"MT" => {35.83333333, 14.58333333},
"MH" => {9, 168},
"MQ" => {14.666667, -61},
"MR" => {20, -12},
"MU" => {-20.28333333, 57.55},
"YT" => {-12.83333333, 45.16666666},
"MX" => {23, -102},
"FM" => {6.91666666, 158.25},
"MD" => {47, 29},
"MC" => {43.73333333, 7.4},
"MN" => {46, 105},
"ME" => {42.5, 19.3},
"MS" => {16.75, -62.2},
"MA" => {32, -5},
"MZ" => {-18.25, 35},
"MM" => {22, 98},
"NA" => {-22, 17},
"NR" => {-0.53333333, 166.91666666},
"NP" => {28, 84},
"NL" => {52.5, 5.75},
"NC" => {-21.5, 165.5},
"NZ" => {-41, 174},
"NI" => {13, -85},
"NE" => {16, 8},
"NG" => {10, 8},
"NU" => {-19.03333333, -169.86666666},
"NF" => {-29.03333333, 167.95},
"KP" => {40, 127},
"MP" => {15.2, 145.75},
"NO" => {62, 10},
"OM" => {21, 57},
"PK" => {30, 70},
"PW" => {7.5, 134.5},
"PS" => {31.9, 35.2},
"PA" => {9, -80},
"PG" => {-6, 147},
"PY" => {-23, -58},
"PE" => {-10, -76},
"PH" => {13, 122},
"PN" => {-25.06666666, -130.1},
"PL" => {52, 20},
"PT" => {39.5, -8},
"PR" => {18.25, -66.5},
"QA" => {25.5, 51.25},
"XK" => {42.666667, 21.166667},
"RE" => {-21.15, 55.5},
"RO" => {46, 25},
"RU" => {60, 100},
"RW" => {-2, 30},
"BL" => {18.5, -63.41666666},
"SH" => {-15.95, -5.7},
"KN" => {17.33333333, -62.75},
"LC" => {13.88333333, -60.96666666},
"MF" => {18.08333333, -63.95},
"PM" => {46.83333333, -56.33333333},
"VC" => {13.25, -61.2},
"WS" => {-13.58333333, -172.33333333},
"SM" => {43.76666666, 12.41666666},
"ST" => {1, 7},
"SA" => {25, 45},
"SN" => {14, -14},
"RS" => {44, 21},
"SC" => {-4.58333333, 55.66666666},
"SL" => {8.5, -11.5},
"SG" => {1.36666666, 103.8},
"SX" => {18.033333, -63.05},
"SK" => {48.66666666, 19.5},
"SI" => {46.11666666, 14.81666666},
"SB" => {-8, 159},
"SO" => {10, 49},
"ZA" => {-29, 24},
"GS" => {-54.5, -37},
"KR" => {37, 127.5},
"ES" => {40, -4},
"LK" => {7, 81},
"SD" => {15, 30},
"SS" => {7, 30},
"SR" => {4, -56},
"SJ" => {78, 20},
"SZ" => {-26.5, 31.5},
"SE" => {62, 15},
"CH" => {47, 8},
"SY" => {35, 38},
"TW" => {23.5, 121},
"TJ" => {39, 71},
"TZ" => {-6, 35},
"TH" => {15, 100},
"TL" => {-8.83333333, 125.91666666},
"TG" => {8, 1.16666666},
"TK" => {-9, -172},
"TO" => {-20, -175},
"TT" => {11, -61},
"TN" => {34, 9},
"TR" => {39, 35},
"TM" => {40, 60},
"TC" => {21.75, -71.58333333},
"TV" => {-8, 178},
"UG" => {1, 32},
"UA" => {49, 32},
"AE" => {24, 54},
"GB" => {54, -2},
"US" => {38, -97},
"UY" => {-33, -56},
"UZ" => {41, 64},
"VU" => {-16, 167},
"VE" => {8, -66},
"VN" => {16.16666666, 107.83333333},
"WF" => {-13.3, -176.2},
"EH" => {24.5, -13},
"YE" => {15, 48},
"ZM" => {-15, 30},
"ZW" => {-20, 30}
}
|> Enum.map(fn {code, {lat, lon}} -> {code, {lat * 1.0, lon * 1.0}} end)
|> Map.new()
# ISO 3166-1 alpha-2
# https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2
@counties %{
"LT" => %{common_name: "Lithuania", coordinates: {56.0, 24.0}},
"GU" => %{common_name: "Guam", coordinates: {13.46666666, 144.78333333}},
"BA" => %{common_name: "Bosnia and Herzegovina", coordinates: {44.0, 18.0}},
"LI" => %{common_name: "Liechtenstein", coordinates: {47.26666666, 9.53333333}},
"CZ" => %{common_name: "Czechia", coordinates: {49.75, 15.5}},
"AD" => %{common_name: "Andorra", coordinates: {42.5, 1.5}},
"MT" => %{common_name: "Malta", coordinates: {35.83333333, 14.58333333}},
"MX" => %{common_name: "Mexico", coordinates: {23.0, -102.0}},
"TH" => %{common_name: "Thailand", coordinates: {15.0, 100.0}},
"EH" => %{common_name: "Western Sahara", coordinates: {24.5, -13.0}},
"LR" => %{common_name: "Liberia", coordinates: {6.5, -9.5}},
"HK" => %{common_name: "Hong Kong", coordinates: {22.25, 114.16666666}},
"SZ" => %{common_name: "Eswatini", coordinates: {-26.5, 31.5}},
"TD" => %{common_name: "Chad", coordinates: {15.0, 19.0}},
"TR" => %{common_name: "Türkiye", coordinates: {39.0, 35.0}},
"KG" => %{common_name: "Kyrgyzstan", coordinates: {41.0, 75.0}},
"YE" => %{common_name: "Yemen", coordinates: {15.0, 48.0}},
"SJ" => %{common_name: "Svalbard and Jan Mayen", coordinates: {78.0, 20.0}},
"IN" => %{common_name: "India", coordinates: {20.0, 77.0}},
"FO" => %{common_name: "Faroe Islands", coordinates: {62.0, -7.0}},
"US" => %{common_name: "United States of America", coordinates: {38.0, -97.0}},
"SD" => %{common_name: "Sudan", coordinates: {15.0, 30.0}},
"IR" => %{common_name: "Iran", coordinates: {32.0, 53.0}},
"CW" => %{common_name: "Curaçao", coordinates: {12.116667, -68.933333}},
"SE" => %{common_name: "Sweden", coordinates: {62.0, 15.0}},
"LK" => %{common_name: "Sri Lanka", coordinates: {7.0, 81.0}},
"KH" => %{common_name: "Cambodia", coordinates: {13.0, 105.0}},
"CN" => %{common_name: "China", coordinates: {35.0, 105.0}},
"SA" => %{common_name: "Saudi Arabia", coordinates: {25.0, 45.0}},
"IM" => %{common_name: "Isle of Man", coordinates: {54.25, -4.5}},
"GY" => %{common_name: "Guyana", coordinates: {5.0, -59.0}},
"ST" => %{common_name: "Sao Tome and Principe", coordinates: {1.0, 7.0}},
"AL" => %{common_name: "Albania", coordinates: {41.0, 20.0}},
"SO" => %{common_name: "Somalia", coordinates: {10.0, 49.0}},
"BS" => %{common_name: "Bahamas", coordinates: {24.25, -76.0}},
"GM" => %{common_name: "Gambia", coordinates: {13.46666666, -16.56666666}},
"ES" => %{common_name: "Spain", coordinates: {40.0, -4.0}},
"RW" => %{common_name: "Rwanda", coordinates: {-2.0, 30.0}},
"EE" => %{common_name: "Estonia", coordinates: {59.0, 26.0}},
"HN" => %{common_name: "Honduras", coordinates: {15.0, -86.5}},
"MQ" => %{common_name: "Martinique", coordinates: {14.666667, -61.0}},
"EG" => %{common_name: "Egypt", coordinates: {27.0, 30.0}},
"GI" => %{common_name: "Gibraltar", coordinates: {36.13333333, -5.35}},
"CC" => %{common_name: "Cocos (Keeling) Islands", coordinates: {-12.5, 96.83333333}},
"MA" => %{common_name: "Morocco", coordinates: {32.0, -5.0}},
"MC" => %{common_name: "Monaco", coordinates: {43.73333333, 7.4}},
"DE" => %{common_name: "Germany", coordinates: {51.0, 9.0}},
"YT" => %{common_name: "Mayotte", coordinates: {-12.83333333, 45.16666666}},
"KI" => %{common_name: "Kiribati", coordinates: {1.41666666, 173.0}},
"CU" => %{common_name: "Cuba", coordinates: {21.5, -80.0}},
"GL" => %{common_name: "Greenland", coordinates: {72.0, -40.0}},
"CH" => %{common_name: "Switzerland", coordinates: {47.0, 8.0}},
"BY" => %{common_name: "Belarus", coordinates: {53.0, 28.0}},
"NF" => %{common_name: "Norfolk Island", coordinates: {-29.03333333, 167.95}},
"SS" => %{common_name: "South Sudan", coordinates: {7.0, 30.0}},
"GR" => %{common_name: "Greece", coordinates: {39.0, 22.0}},
"DO" => %{common_name: "Dominican Republic", coordinates: {19.0, -70.66666666}},
"CI" => %{common_name: "Côte d'Ivoire", coordinates: {8.0, -5.0}},
"BQ" => %{common_name: "Bonaire", coordinates: {12.15, -68.266667}},
"KN" => %{common_name: "Saint Kitts and Nevis", coordinates: {17.33333333, -62.75}},
"KE" => %{common_name: "Kenya", coordinates: {1.0, 38.0}},
"PL" => %{common_name: "Poland", coordinates: {52.0, 20.0}},
"RO" => %{common_name: "Romania", coordinates: {46.0, 25.0}},
"BI" => %{common_name: "Burundi", coordinates: {-3.5, 30.0}},
"BO" => %{common_name: "Bolivia", coordinates: {-17.0, -65.0}},
"SX" => %{common_name: "Sint Maarten (Dutch part)", coordinates: {18.033333, -63.05}},
"CY" => %{common_name: "Cyprus", coordinates: {35.0, 33.0}},
"CL" => %{common_name: "Chile", coordinates: {-30.0, -71.0}},
"TL" => %{common_name: "Timor-Leste", coordinates: {-8.83333333, 125.91666666}},
"AU" => %{common_name: "Australia", coordinates: {-27.0, 133.0}},
"KP" => %{common_name: "North Korea", coordinates: {40.0, 127.0}},
"WF" => %{common_name: "Wallis and Futuna", coordinates: {-13.3, -176.2}},
"MY" => %{common_name: "Malaysia", coordinates: {2.5, 112.5}},
"MV" => %{common_name: "Maldives", coordinates: {3.25, 73.0}},
"TG" => %{common_name: "Togo", coordinates: {8.0, 1.16666666}},
"FI" => %{common_name: "Finland", coordinates: {64.0, 26.0}},
"MP" => %{common_name: "Northern Mariana Islands", coordinates: {15.2, 145.75}},
"RS" => %{common_name: "Serbia", coordinates: {44.0, 21.0}},
"NA" => %{common_name: "Namibia", coordinates: {-22.0, 17.0}},
"SI" => %{common_name: "Slovenia", coordinates: {46.11666666, 14.81666666}},
"GD" => %{common_name: "Grenada", coordinates: {12.11666666, -61.66666666}},
"VU" => %{common_name: "Vanuatu", coordinates: {-16.0, 167.0}},
"GW" => %{common_name: "Guinea-Bissau", coordinates: {12.0, -15.0}},
"GT" => %{common_name: "Guatemala", coordinates: {15.5, -90.25}},
"IQ" => %{common_name: "Iraq", coordinates: {33.0, 44.0}},
"BJ" => %{common_name: "Benin", coordinates: {9.5, 2.25}},
"BZ" => %{common_name: "Belize", coordinates: {17.25, -88.75}},
"GQ" => %{common_name: "Equatorial Guinea", coordinates: {2.0, 10.0}},
"MN" => %{common_name: "Mongolia", coordinates: {46.0, 105.0}},
"CX" => %{common_name: "Christmas Island", coordinates: {-10.5, 105.66666666}},
"MZ" => %{common_name: "Mozambique", coordinates: {-18.25, 35.0}},
"JM" => %{common_name: "Jamaica", coordinates: {18.25, -77.5}},
"UM" => %{
common_name: "United States Minor Outlying Islands",
coordinates: {13.9172255, -134.1859535}
},
"IE" => %{common_name: "Ireland", coordinates: {53.0, -8.0}},
"CR" => %{common_name: "Costa Rica", coordinates: {10.0, -84.0}},
"PM" => %{common_name: "Saint Pierre and Miquelon", coordinates: {46.83333333, -56.33333333}},
"MD" => %{common_name: "Moldova", coordinates: {47.0, 29.0}},
"PR" => %{common_name: "Puerto Rico", coordinates: {18.25, -66.5}},
"MO" => %{common_name: "Macao", coordinates: {22.16666666, 113.55}},
"TO" => %{common_name: "Tonga", coordinates: {-20.0, -175.0}},
"AO" => %{common_name: "Angola", coordinates: {-12.5, 18.5}},
"AQ" => %{common_name: "Antarctica", coordinates: {-74.65, 4.48}},
"IT" => %{common_name: "Italy", coordinates: {42.83333333, 12.83333333}},
"TV" => %{common_name: "Tuvalu", coordinates: {-8.0, 178.0}},
"SH" => %{common_name: "Saint Helena", coordinates: {-15.95, -5.7}},
"ME" => %{common_name: "Montenegro", coordinates: {42.5, 19.3}},
"GB" => %{
common_name: "United Kingdom of Great Britain and Northern Ireland",
coordinates: {54.0, -2.0}
},
"FK" => %{common_name: "Falkland Islands (Malvinas)", coordinates: {-51.75, -59.0}},
"NO" => %{common_name: "Norway", coordinates: {62.0, 10.0}},
"DM" => %{common_name: "Dominica", coordinates: {15.41666666, -61.33333333}},
"PE" => %{common_name: "Peru", coordinates: {-10.0, -76.0}},
"NR" => %{common_name: "Nauru", coordinates: {-0.53333333, 166.91666666}},
"MS" => %{common_name: "Montserrat", coordinates: {16.75, -62.2}},
"PW" => %{common_name: "Palau", coordinates: {7.5, 134.5}},
"KM" => %{common_name: "Comoros", coordinates: {-12.16666666, 44.25}},
"AF" => %{common_name: "Afghanistan", coordinates: {33.0, 65.0}},
"MM" => %{common_name: "Myanmar", coordinates: {22.0, 98.0}},
"CK" => %{common_name: "Cook Islands", coordinates: {-21.23333333, -159.76666666}},
"MU" => %{common_name: "Mauritius", coordinates: {-20.28333333, 57.55}},
"BD" => %{common_name: "Bangladesh", coordinates: {24.0, 90.0}},
"FJ" => %{common_name: "Fiji", coordinates: {-18.0, 175.0}},
"UG" => %{common_name: "Uganda", coordinates: {1.0, 32.0}},
"RU" => %{common_name: "Russia", coordinates: {60.0, 100.0}},
"GN" => %{common_name: "Guinea", coordinates: {11.0, -10.0}},
"BM" => %{common_name: "Bermuda", coordinates: {32.33333333, -64.75}},
"JP" => %{common_name: "Japan", coordinates: {36.0, 138.0}},
"TT" => %{common_name: "Trinidad and Tobago", coordinates: {11.0, -61.0}},
"IS" => %{common_name: "Iceland", coordinates: {65.0, -18.0}},
"FM" => %{common_name: "Micronesia", coordinates: {6.91666666, 158.25}},
"KY" => %{common_name: "Cayman Islands", coordinates: {19.5, -80.5}},
"MH" => %{common_name: "Marshall Islands", coordinates: {9.0, 168.0}},
"UA" => %{common_name: "Ukraine", coordinates: {49.0, 32.0}},
"NC" => %{common_name: "New Caledonia", coordinates: {-21.5, 165.5}},
"MW" => %{common_name: "Malawi", coordinates: {-13.5, 34.0}},
"TF" => %{common_name: "French Southern Territories", coordinates: {-49.25, 69.167}},
"LS" => %{common_name: "Lesotho", coordinates: {-29.5, 28.5}},
"IO" => %{common_name: "British Indian Ocean Territory", coordinates: {-6.0, 71.5}},
"SN" => %{common_name: "Senegal", coordinates: {14.0, -14.0}},
"DZ" => %{common_name: "Algeria", coordinates: {28.0, 3.0}},
"AW" => %{common_name: "Aruba", coordinates: {12.5, -69.96666666}},
"GS" => %{
common_name: "South Georgia and the South Sandwich Islands",
coordinates: {-54.5, -37.0}
},
"SM" => %{common_name: "San Marino", coordinates: {43.76666666, 12.41666666}},
"PA" => %{common_name: "Panama", coordinates: {9.0, -80.0}},
"JO" => %{common_name: "Jordan", coordinates: {31.0, 36.0}},
"VE" => %{common_name: "Venezuela", coordinates: {8.0, -66.0}},
"AE" => %{common_name: "United Arab Emirates", coordinates: {24.0, 54.0}},
"TJ" => %{common_name: "Tajikistan", coordinates: {39.0, 71.0}},
"BT" => %{common_name: "Bhutan", coordinates: {27.5, 90.5}},
"TC" => %{common_name: "Turks and Caicos Islands", coordinates: {21.75, -71.58333333}},
"NP" => %{common_name: "Nepal", coordinates: {28.0, 84.0}},
"LB" => %{common_name: "Lebanon", coordinates: {33.83333333, 35.83333333}},
"HU" => %{common_name: "Hungary", coordinates: {47.0, 20.0}},
"LU" => %{common_name: "Luxembourg", coordinates: {49.75, 6.16666666}},
"DK" => %{common_name: "Denmark", coordinates: {56.0, 10.0}},
"BF" => %{common_name: "Burkina Faso", coordinates: {13.0, -2.0}},
"VA" => %{common_name: "Holy See", coordinates: {41.9, 12.45}},
"SK" => %{common_name: "Slovakia", coordinates: {48.66666666, 19.5}},
"UZ" => %{common_name: "Uzbekistan", coordinates: {41.0, 64.0}},
"NG" => %{common_name: "Nigeria", coordinates: {10.0, 8.0}},
"AG" => %{common_name: "Antigua and Barbuda", coordinates: {17.05, -61.8}},
"EC" => %{common_name: "Ecuador", coordinates: {-2.0, -77.5}},
"SC" => %{common_name: "Seychelles", coordinates: {-4.58333333, 55.66666666}},
"AR" => %{common_name: "Argentina", coordinates: {-34.0, -64.0}},
"BW" => %{common_name: "Botswana", coordinates: {-22.0, 24.0}},
"BE" => %{common_name: "Belgium", coordinates: {50.83333333, 4.0}},
"TM" => %{common_name: "Turkmenistan", coordinates: {40.0, 60.0}},
"VN" => %{common_name: "Vietnam", coordinates: {16.16666666, 107.83333333}},
"PH" => %{common_name: "Philippines", coordinates: {13.0, 122.0}},
"SG" => %{common_name: "Singapore", coordinates: {1.36666666, 103.8}},
"TK" => %{common_name: "Tokelau", coordinates: {-9.0, -172.0}},
"BV" => %{common_name: "Bouvet Island", coordinates: {-54.43333333, 3.4}},
"MG" => %{common_name: "Madagascar", coordinates: {-20.0, 47.0}},
"GP" => %{common_name: "Guadeloupe", coordinates: {16.25, -61.583333}},
"ET" => %{common_name: "Ethiopia", coordinates: {8.0, 38.0}},
"CM" => %{common_name: "Cameroon", coordinates: {6.0, 12.0}},
"AX" => %{common_name: "Åland Islands", coordinates: {60.116667, 19.9}},
"BL" => %{common_name: "Saint Barthélemy", coordinates: {18.5, -63.41666666}},
"SY" => %{common_name: "Syria", coordinates: {35.0, 38.0}},
"MR" => %{common_name: "Mauritania", coordinates: {20.0, -12.0}},
"LY" => %{common_name: "Libya", coordinates: {25.0, 17.0}},
"RE" => %{common_name: "Réunion", coordinates: {-21.15, 55.5}},
"HM" => %{common_name: "Heard Island and McDonald Islands", coordinates: {-53.1, 72.51666666}},
"VG" => %{common_name: "Virgin Islands (British)", coordinates: {18.431383, -64.62305}},
"AZ" => %{common_name: "Azerbaijan", coordinates: {40.5, 47.5}},
"CF" => %{common_name: "Central African Republic", coordinates: {7.0, 21.0}},
"AT" => %{common_name: "Austria", coordinates: {47.33333333, 13.33333333}},
"BH" => %{common_name: "Bahrain", coordinates: {26.0, 50.55}},
"PT" => %{common_name: "Portugal", coordinates: {39.5, -8.0}},
"TN" => %{common_name: "Tunisia", coordinates: {34.0, 9.0}},
"TZ" => %{common_name: "Tanzania", coordinates: {-6.0, 35.0}},
"ZA" => %{common_name: "South Africa", coordinates: {-29.0, 24.0}},
"VC" => %{common_name: "Saint Vincent and the Grenadines", coordinates: {13.25, -61.2}},
"PK" => %{common_name: "Pakistan", coordinates: {30.0, 70.0}},
"PY" => %{common_name: "Paraguay", coordinates: {-23.0, -58.0}},
"CD" => %{common_name: "Congo", coordinates: {0.0, 25.0}},
"WS" => %{common_name: "Samoa", coordinates: {-13.58333333, -172.33333333}},
"UY" => %{common_name: "Uruguay", coordinates: {-33.0, -56.0}},
"SB" => %{common_name: "Solomon Islands", coordinates: {-8.0, 159.0}},
"GE" => %{common_name: "Georgia", coordinates: {42.0, 43.5}},
"GA" => %{common_name: "Gabon", coordinates: {-1.0, 11.75}},
"HT" => %{common_name: "Haiti", coordinates: {19.0, -72.41666666}},
"BG" => %{common_name: "Bulgaria", coordinates: {43.0, 25.0}},
"OM" => %{common_name: "Oman", coordinates: {21.0, 57.0}},
"CA" => %{common_name: "Canada", coordinates: {60.0, -95.0}},
"KR" => %{common_name: "South Korea", coordinates: {37.0, 127.5}},
"ER" => %{common_name: "Eritrea", coordinates: {15.0, 39.0}},
"GF" => %{common_name: "French Guiana", coordinates: {4.0, -53.0}},
"LV" => %{common_name: "Latvia", coordinates: {57.0, 25.0}},
"AI" => %{common_name: "Anguilla", coordinates: {18.25, -63.16666666}},
"ZW" => %{common_name: "Zimbabwe", coordinates: {-20.0, 30.0}},
"BN" => %{common_name: "Brunei Darussalam", coordinates: {4.5, 114.66666666}},
"SV" => %{common_name: "El Salvador", coordinates: {13.83333333, -88.91666666}},
"NL" => %{common_name: "Netherlands", coordinates: {52.5, 5.75}},
"PG" => %{common_name: "Papua New Guinea", coordinates: {-6.0, 147.0}},
"GG" => %{common_name: "Guernsey", coordinates: {49.46666666, -2.58333333}},
"TW" => %{common_name: "Taiwan", coordinates: {23.5, 121.0}},
"CO" => %{common_name: "Colombia", coordinates: {4.0, -72.0}},
"SR" => %{common_name: "Suriname", coordinates: {4.0, -56.0}},
"HR" => %{common_name: "Croatia", coordinates: {45.16666666, 15.5}},
"JE" => %{common_name: "Jersey", coordinates: {49.25, -2.16666666}},
"LA" => %{common_name: "Laos", coordinates: {18.0, 105.0}},
"KW" => %{common_name: "Kuwait", coordinates: {29.5, 45.75}},
"PS" => %{common_name: "Palestine", coordinates: {31.9, 35.2}},
"ZM" => %{common_name: "Zambia", coordinates: {-15.0, 30.0}},
"NI" => %{common_name: "Nicaragua", coordinates: {13.0, -85.0}},
"SL" => %{common_name: "Sierra Leone", coordinates: {8.5, -11.5}},
"GH" => %{common_name: "Ghana", coordinates: {8.0, -2.0}},
"IL" => %{common_name: "Israel", coordinates: {31.5, 34.75}},
"AS" => %{common_name: "American Samoa", coordinates: {-14.33333333, -170.0}},
"MF" => %{common_name: "Saint Martin (French part)", coordinates: {18.08333333, -63.95}},
"NZ" => %{common_name: "New Zealand", coordinates: {-41.0, 174.0}},
"BB" => %{common_name: "Barbados", coordinates: {13.16666666, -59.53333333}},
"NU" => %{common_name: "Niue", coordinates: {-19.03333333, -169.86666666}},
"DJ" => %{common_name: "Djibouti", coordinates: {11.5, 43.0}},
"BR" => %{common_name: "Brazil", coordinates: {-10.0, -55.0}},
"LC" => %{common_name: "Saint Lucia", coordinates: {13.88333333, -60.96666666}},
"QA" => %{common_name: "Qatar", coordinates: {25.5, 51.25}},
"ID" => %{common_name: "Indonesia", coordinates: {-5.0, 120.0}},
"NE" => %{common_name: "Niger", coordinates: {16.0, 8.0}},
"MK" => %{common_name: "North Macedonia", coordinates: {41.83333333, 22.0}},
"FR" => %{common_name: "France", coordinates: {46.0, 2.0}},
"PN" => %{common_name: "Pitcairn", coordinates: {-25.06666666, -130.1}},
"AM" => %{common_name: "Armenia", coordinates: {40.0, 45.0}},
"ML" => %{common_name: "Mali", coordinates: {17.0, -4.0}},
"VI" => %{common_name: "Virgin Islands (U.S.)", coordinates: {18.34, -64.93}},
"KZ" => %{common_name: "Kazakhstan", coordinates: {48.0, 68.0}},
"CG" => %{common_name: "Congo", coordinates: {-1.0, 15.0}},
"CV" => %{common_name: "Cabo Verde", coordinates: {16.0, -24.0}},
"PF" => %{common_name: "French Polynesia", coordinates: {-15.0, -140.0}}
}
def distance({lat1, lon1}, {lat2, lon2}) do
d_lat = degrees_to_radians(lat2 - lat1)
@@ -274,13 +283,32 @@ defmodule Domain.Geo do
end
def maybe_put_default_coordinates(country_code, {nil, nil}) do
case Map.get(@coordinates_by_code, country_code) do
{lat, lon} -> {lat, lon}
nil -> {nil, nil}
with {:ok, country} <- Map.fetch(@counties, country_code),
{:ok, {lat, lon}} <- Map.fetch(country, :coordinates) do
{lat, lon}
else
_other -> {nil, nil}
end
end
def maybe_put_default_coordinates(_country_code, {lat, lon}) do
{lat, lon}
end
def all_country_codes! do
Enum.map(@counties, fn {code, _} -> code end)
end
def all_country_options! do
@counties
|> Enum.map(fn {code, %{common_name: common_name}} -> {common_name, code} end)
|> Enum.sort_by(fn {common_name, _} -> common_name end)
end
def country_common_name!(country_code) do
case Map.fetch(@counties, country_code) do
{:ok, %{common_name: common_name}} -> common_name
:error -> country_code
end
end
end

View File

@@ -1,7 +1,7 @@
defmodule Domain.Policies do
alias Domain.{Repo, PubSub}
alias Domain.{Auth, Accounts, Actors, Resources, Flows}
alias Domain.Policies.{Authorizer, Policy}
alias Domain.{Auth, Accounts, Actors, Clients, Resources, Flows}
alias Domain.Policies.{Authorizer, Policy, Condition}
def fetch_policy_by_id(id, %Auth.Subject{} = subject, opts \\ []) do
required_permissions =
@@ -165,6 +165,16 @@ defmodule Domain.Policies do
{:ok, policies}
end
def ensure_client_conforms_policy_conditions(%Clients.Client{} = client, %Policy{} = policy) do
case Condition.Evaluator.ensure_conforms(policy.conditions, client) do
:ok ->
:ok
{:error, violated_properties} ->
{:error, {:forbidden, violated_properties: violated_properties}}
end
end
def ensure_has_access_to(%Auth.Subject{} = subject, %Policy{} = policy) do
if subject.account.id == policy.account_id do
:ok

View File

@@ -0,0 +1,18 @@
defmodule Domain.Policies.Condition do
use Domain, :schema
@primary_key false
embedded_schema do
field :property, Ecto.Enum,
values: ~w[remote_ip_location_region remote_ip provider_id current_utc_datetime]a
field :operator, Ecto.Enum, values: ~w[
contains does_not_contain
is_in is_not_in
is_in_day_of_week_time_ranges
is_in_cidr is_not_in_cidr
]a
field :values, {:array, :string}
end
end

View File

@@ -0,0 +1,66 @@
defmodule Domain.Policies.Condition.Changeset do
use Domain, :changeset
alias Domain.Policies.Condition
def changeset(%Condition{} = condition, attrs, _position) do
condition
|> cast(attrs, [:property, :operator, :values])
|> put_default_value(:property, :remote_ip_location_region)
|> put_default_value(:operator, :is_in)
|> validate_required([:property, :operator])
|> validate_operator()
end
def valid_operators_for_property(:remote_ip_location_region), do: [:is_in, :is_not_in]
def valid_operators_for_property(:remote_ip), do: [:is_in_cidr, :is_not_in_cidr]
def valid_operators_for_property(:provider_id), do: [:is_in, :is_not_in]
def valid_operators_for_property(:current_utc_datetime), do: [:is_in_day_of_week_time_ranges]
defp validate_operator(changeset) do
case fetch_field(changeset, :property) do
{_data_or_changes, :remote_ip_location_region} ->
changeset
|> validate_required(:operator)
|> validate_inclusion(:operator, valid_operators_for_property(:remote_ip_location_region))
|> validate_subset(:values, Domain.Geo.all_country_codes!())
{_data_or_changes, :remote_ip} ->
changeset
|> validate_required(:operator)
|> validate_inclusion(:operator, valid_operators_for_property(:remote_ip))
|> validate_list(:values, Domain.Types.INET)
{_data_or_changes, :provider_id} ->
changeset
|> validate_required(:operator)
|> validate_inclusion(:operator, valid_operators_for_property(:provider_id))
|> validate_list(:values, Ecto.UUID)
{_data_or_changes, :current_utc_datetime} ->
changeset
|> validate_required(:operator)
|> validate_inclusion(:operator, valid_operators_for_property(:current_utc_datetime))
|> validate_list(:values, :string, fn changeset, field ->
validate_day_of_week_time_ranges(changeset, field)
end)
{_data_or_changes, nil} ->
changeset
:error ->
add_error(changeset, :property, "is not supported")
end
end
def validate_day_of_week_time_ranges(changeset, field) do
validate_change(changeset, field, fn field, value ->
case Condition.Evaluator.parse_day_of_week_time_ranges(value) do
{:ok, _dow_time_ranges} ->
[]
{:error, reason} ->
[{field, reason}]
end
end)
end
end

View File

@@ -0,0 +1,237 @@
defmodule Domain.Policies.Condition.Evaluator do
alias Domain.Repo
alias Domain.Clients
alias Domain.Policies.Condition
@days_of_week ~w[M T W R F S U]
def ensure_conforms([], %Clients.Client{}) do
:ok
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]
end
end)
|> case do
[] -> :ok
violated_properties -> {:error, Enum.reverse(violated_properties)}
end
end
def conforms?(
%Condition{property: :remote_ip_location_region, operator: :is_in, values: values},
%Clients.Client{} = client
) do
client.last_seen_remote_ip_location_region in values
end
def conforms?(
%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
end
def conforms?(
%Condition{property: :remote_ip, operator: :is_in_cidr, values: values},
%Clients.Client{} = client
) do
Enum.any?(values, fn cidr ->
{: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)
end)
end
def conforms?(
%Condition{property: :remote_ip, operator: :is_not_in_cidr, values: values},
%Clients.Client{} = client
) do
Enum.all?(values, fn cidr ->
{: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)
end)
end
def conforms?(
%Condition{property: :provider_id, operator: :is_in, values: values},
%Clients.Client{} = client
) do
client = Repo.preload(client, :identity)
client.identity.provider_id in values
end
def conforms?(
%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
end
def conforms?(
%Condition{
property: :current_utc_datetime,
operator: :is_in_day_of_week_time_ranges,
values: values
},
%Clients.Client{}
) do
datetime_in_day_of_the_week_time_ranges?(DateTime.utc_now(), values)
end
def datetime_in_day_of_the_week_time_ranges?(datetime, dow_time_ranges) 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} ->
datetime_in_time_ranges?(datetime, day, time_ranges)
end)
{:error, _reason} ->
false
end
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)
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
end
end)
end
def parse_days_of_week_time_ranges(dows_time_ranges) do
Enum.reduce_while(dows_time_ranges, {:ok, %{}}, fn dow_time_ranges, {:ok, acc} ->
case parse_day_of_week_time_ranges(dow_time_ranges) do
{:ok, {day, dow_time_ranges}} ->
{_current_value, acc} =
Map.get_and_update(acc, day, fn
nil -> {nil, dow_time_ranges}
current_value -> {current_value, current_value ++ dow_time_ranges}
end)
{:cont, {:ok, acc}}
{:error, reason} ->
{:halt, {:error, reason}}
end
end)
end
def parse_day_of_week_time_ranges(dow_time_ranges) do
case String.split(dow_time_ranges, "/", parts: 3) do
[day, time_ranges, timezone] when day in @days_of_week ->
with {:ok, time_ranges} <- parse_time_ranges(time_ranges, timezone) do
{:ok, {day, time_ranges}}
end
[_day, _time_ranges] ->
{:error, "timezone is required"}
_ ->
{:error, "invalid day of the week, must be one of #{Enum.join(@days_of_week, ", ")}"}
end
end
def parse_time_ranges(time_ranges, timezone) do
with true <- Tzdata.zone_exists?(timezone),
{:ok, time_ranges} <- parse_time_ranges(time_ranges) do
time_ranges =
Enum.map(time_ranges, fn {start_time, end_time} ->
{start_time, end_time, timezone}
end)
{:ok, time_ranges}
else
false ->
{:error, "invalid timezone"}
{:error, reason} ->
{:error, reason}
end
end
def parse_time_ranges(nil) do
{:ok, []}
end
def parse_time_ranges(time_ranges) do
String.split(time_ranges, ",", trim: true)
|> Enum.reduce_while({:ok, []}, fn time_range, {:ok, acc} ->
time_range
|> String.trim()
|> parse_time_range()
|> case do
{:ok, time} ->
{:cont, {:ok, [time | acc]}}
{:error, reason} ->
{:halt, {:error, reason}}
end
end)
|> case do
{:ok, time_ranges} ->
{:ok, Enum.reverse(time_ranges)}
{:error, reason} ->
{:error, reason}
end
end
def parse_time_range("true") do
{:ok, {~T[00:00:00], ~T[23:59:59]}}
end
def parse_time_range(time_range) do
with [start_time, end_time] <- String.split(time_range, "-", parts: 2, trim: true),
{:ok, start_time} <- parse_time(start_time),
{:ok, end_time} <- parse_time(end_time),
true <- Time.compare(start_time, end_time) != :gt do
{:ok, {start_time, end_time}}
else
false ->
{:error, "start of the time range must be less than or equal to the end of it"}
_ ->
{:error, "invalid time range: #{time_range}"}
end
end
defp parse_time(time) do
[time | _tail] = String.split(time, ".", parts: 2)
case String.split(time, ":", parts: 3) do
[hours] ->
Time.from_iso8601(pad2(hours) <> ":00:00")
[hours, minutes] ->
Time.from_iso8601(pad2(hours) <> ":" <> pad2(minutes) <> ":00")
[hours, minutes, seconds] ->
Time.from_iso8601(pad2(hours) <> ":" <> pad2(minutes) <> ":" <> pad2(seconds))
_ ->
{:error, "invalid time: #{time}"}
end
end
defp pad2(str_int) when byte_size(str_int) == 1, do: "0#{str_int}"
defp pad2(str_int), do: str_int
end

View File

@@ -4,6 +4,8 @@ defmodule Domain.Policies.Policy do
schema "policies" do
field :description, :string
embeds_many :conditions, Domain.Policies.Condition, on_replace: :delete
belongs_to :actor_group, Domain.Actors.Group
belongs_to :resource, Domain.Resources.Resource
belongs_to :account, Domain.Accounts.Account

View File

@@ -11,6 +11,7 @@ defmodule Domain.Policies.Policy.Changeset do
%Policy{}
|> cast(attrs, @fields)
|> validate_required(@required_fields)
|> cast_embed(:conditions, with: &Domain.Policies.Condition.Changeset.changeset/3)
|> changeset()
|> put_change(:account_id, subject.account.id)
|> put_change(:created_by, :identity)
@@ -21,6 +22,7 @@ defmodule Domain.Policies.Policy.Changeset do
policy
|> cast(attrs, @update_fields)
|> validate_required(@required_fields)
|> cast_embed(:conditions, with: &Domain.Policies.Condition.Changeset.changeset/3)
|> changeset()
end

View File

@@ -7,6 +7,34 @@ defmodule Domain.Repo.Changeset do
# Helpers
def set_action(changesets, action) when is_list(changesets) do
Enum.map(changesets, &set_action(&1, action))
end
def set_action(%Ecto.Changeset{} = changeset, action) do
assocs =
Enum.flat_map(changeset.types, fn
{field, {:assoc, _params}} -> [field]
{field, {:embed, _params}} -> [field]
_ -> []
end)
changeset =
Enum.reduce(changeset.changes, changeset, fn {key, value}, changeset ->
if key in assocs do
%{changeset | changes: Map.put(changeset.changes, key, set_action(value, action))}
else
changeset
end
end)
%{changeset | action: action}
end
def set_action(other, _action) do
other
end
def has_errors?(%Ecto.Changeset{} = changeset, field) do
Keyword.has_key?(changeset.errors, field)
end
@@ -113,6 +141,43 @@ defmodule Domain.Repo.Changeset do
# Validations
def validate_list(
%Ecto.Changeset{} = changeset,
field,
element_type,
fun \\ fn changeset, _field -> changeset end
) do
validate_change(changeset, field, fn _current_field, value ->
cond do
not is_list(value) ->
[{field, "must be a list"}]
Enum.empty?(value) ->
[]
true ->
value
|> Enum.with_index()
|> Enum.flat_map(&validate_list_element(field, fun, element_type, &1))
end
end)
end
defp validate_list_element(field, fun, element_type, {value, index}) do
{%{}, %{value: element_type}}
|> Ecto.Changeset.cast(%{value: value}, [:value])
|> fun.(:value)
|> Ecto.Changeset.apply_action(:insert)
|> case do
{:ok, _} ->
[]
{:error, %{errors: errors}} ->
{error, meta} = errors[:value]
[{field, {error, meta ++ [validated_as: :list, at: index]}}]
end
end
def validate_email(%Ecto.Changeset{} = changeset, field) do
changeset
|> validate_format(field, ~r/^[^\s]+@[^\s]+$/, message: "is an invalid email address")

View File

@@ -21,7 +21,10 @@ defmodule Domain.Resources.Resource do
has_many :policies, Domain.Policies.Policy, where: [deleted_at: nil]
has_many :actor_groups, through: [:policies, :actor_group]
field :authorized_by_policy, :map, virtual: true
# Warning: do not do Repo.preload/2 for this field, it will not work intentionally,
# because the actual preload query should also use joins and process policy conditions
has_many :authorized_by_policies, Domain.Policies.Policy, where: [id: {:fragment, "FALSE"}]
field :created_by, Ecto.Enum, values: ~w[identity]a
belongs_to :created_by_identity, Domain.Auth.Identity

View File

@@ -23,24 +23,18 @@ defmodule Domain.Resources.Resource.Query do
end
def by_authorized_actor_id(queryable, actor_id) do
subquery =
Domain.Policies.Policy.Query.not_disabled()
|> Domain.Policies.Policy.Query.by_actor_id(actor_id)
|> where([policies: policies], policies.resource_id == parent_as(:resources).id)
|> limit(1)
queryable
|> join(
:inner_lateral,
:inner,
[resources: resources],
policies in subquery(subquery),
on: true,
as: :authorized_by_policies
policies in ^Domain.Policies.Policy.Query.not_disabled(),
on: policies.resource_id == resources.id,
as: :policies
)
|> Domain.Policies.Policy.Query.by_actor_id(actor_id)
|> preload([resources: resources, policies: policies],
authorized_by_policies: policies
)
# Note: this will only write one of policies to a map, which means that
# when a client has access to a resource using multiple policies (eg. being a member in multiple groups),
# the policy used will be not deterministic
|> select_merge([authorized_by_policies: policies], %{authorized_by_policy: policies})
end
def with_at_least_one_gateway_group(queryable) do

View File

@@ -120,6 +120,7 @@ defmodule Domain.Types.CIDR do
:ok <- validate_netmask(address, netmask) do
{:ok, %Postgrex.INET{address: address, netmask: netmask}}
else
:invalid_netmask -> {:error, message: "CIDR netmask is invalid or missing"}
_error -> {:error, message: "is invalid"}
end
end
@@ -132,6 +133,7 @@ defmodule Domain.Types.CIDR do
with [binary_address, binary_netmask] <- String.split(binary, "/", parts: 2) do
{:ok, {binary_address, binary_netmask}}
else
[_binary_address] -> :invalid_netmask
_other -> :error
end
end
@@ -145,7 +147,7 @@ defmodule Domain.Types.CIDR do
defp cast_netmask(binary) when is_binary(binary) do
case Integer.parse(binary) do
{netmask, ""} -> {:ok, netmask}
_other -> :error
_other -> :invalid_netmask
end
end

View File

@@ -34,6 +34,9 @@ defmodule Domain.Types.IP do
def load(%Postgrex.INET{} = inet), do: {:ok, inet}
def load(_), do: :error
def type(address) when tuple_size(address) == 4, do: :ipv4
def type(address) when tuple_size(address) == 8, do: :ipv6
def to_string(ip) when is_binary(ip), do: ip
def to_string(%Postgrex.INET{} = inet), do: Domain.Types.INET.to_string(inet)
end

View File

@@ -17,7 +17,8 @@ defmodule Domain.Types.IPPort do
with {:ok, {binary_address, binary_port}} <- parse_binary(binary),
{:ok, address} <- cast_address(binary_address),
{:ok, port} <- cast_port(binary_port) do
{:ok, %__MODULE__{address_type: type(address), address: address, port: port}}
address_type = Domain.Types.IP.type(address)
{:ok, %__MODULE__{address_type: address_type, address: address, port: port}}
else
_error -> {:error, message: "is invalid"}
end
@@ -56,9 +57,6 @@ defmodule Domain.Types.IPPort do
end
end
defp type(address) when tuple_size(address) == 4, do: :ipv4
defp type(address) when tuple_size(address) == 8, do: :ipv6
def dump(%__MODULE__{} = ip) do
{:ok, __MODULE__.to_string(ip)}
end

View File

@@ -75,6 +75,9 @@ defmodule Domain.MixProject do
{:opentelemetry_ecto, "~> 1.2"},
{:opentelemetry_finch, "~> 0.2.0"},
# Other application deps
{:tzdata, "~> 1.1"},
# Test and dev deps
{:bypass, "~> 2.1", only: :test},
{:credo, "~> 1.5", only: [:dev, :test], runtime: false},

View File

@@ -0,0 +1,9 @@
defmodule Domain.Repo.Migrations.AddPoliciesConditions do
use Ecto.Migration
def change do
alter table(:policies) do
add(:conditions, {:array, :map}, default: [])
end
end
end

View File

@@ -25,6 +25,7 @@ account
|> Ecto.Changeset.change(
features: %{
flow_activities: true,
policy_conditions: true,
multi_site_resources: true,
traffic_filters: true,
self_hosted_relays: true,

View File

@@ -47,7 +47,11 @@ defmodule Domain.Config.ValidatorTest do
assert validate(:key, "invalid", type, []) ==
{:error,
{"invalid", ["must be one of: Elixir.Domain.Types.IP, Elixir.Domain.Types.CIDR"]}}
{"invalid",
[
"must be one of: Elixir.Domain.Types.IP, Elixir.Domain.Types.CIDR",
"CIDR netmask is invalid or missing"
]}}
type = {:json_array, {:one_of, [:integer, :boolean]}}

View File

@@ -80,7 +80,151 @@ defmodule Domain.FlowsTest do
authorize_flow(client, gateway, resource.id, subject)
assert fetched_resource.id == resource.id
assert fetched_resource.authorized_by_policy.id == policy.id
assert hd(fetched_resource.authorized_by_policies).id == policy.id
end
test "returns error when some conditions are not satisfied", %{
account: account,
actor_group: actor_group,
client: client,
gateway_group: gateway_group,
gateway: gateway,
subject: subject
} do
resource =
Fixtures.Resources.create_resource(
account: account,
connections: [%{gateway_group_id: gateway_group.id}]
)
Fixtures.Policies.create_policy(
account: account,
actor_group: actor_group,
resource: resource,
conditions: [
%{
property: :remote_ip_location_region,
operator: :is_in,
values: ["AU"]
},
%{
property: :remote_ip_location_region,
operator: :is_not_in,
values: [client.last_seen_remote_ip_location_region]
},
%{
property: :remote_ip,
operator: :is_in_cidr,
values: ["0.0.0.0/0", "0::/0"]
}
]
)
assert authorize_flow(client, gateway, resource.id, subject) ==
{:error, {:forbidden, violated_properties: [:remote_ip_location_region]}}
end
test "returns error when all conditions are not satisfied", %{
account: account,
actor_group: actor_group,
client: client,
gateway_group: gateway_group,
gateway: gateway,
subject: subject
} do
resource =
Fixtures.Resources.create_resource(
account: account,
connections: [%{gateway_group_id: gateway_group.id}]
)
Fixtures.Policies.create_policy(
account: account,
actor_group: actor_group,
resource: resource,
conditions: [
%{
property: :remote_ip_location_region,
operator: :is_in,
values: ["AU"]
},
%{
property: :remote_ip_location_region,
operator: :is_not_in,
values: [client.last_seen_remote_ip_location_region]
}
]
)
assert authorize_flow(client, gateway, resource.id, subject) ==
{:error, {:forbidden, violated_properties: [:remote_ip_location_region]}}
end
test "creates a flow when the only policy conditions are satisfied", %{
account: account,
actor: actor,
resource: resource,
client: client,
policy: policy,
gateway: gateway,
subject: subject
} do
actor_group2 = Fixtures.Actors.create_group(account: account)
Fixtures.Actors.create_membership(account: account, actor: actor, group: actor_group2)
Fixtures.Policies.create_policy(
account: account,
actor_group: actor_group2,
resource: resource,
conditions: [
%{
property: :remote_ip_location_region,
operator: :is_not_in,
values: [client.last_seen_remote_ip_location_region]
}
]
)
assert {:ok, _fetched_resource, flow} =
authorize_flow(client, gateway, resource.id, subject)
assert flow.policy_id == policy.id
end
test "creates a flow when all conditions for at least one of the policies are satisfied", %{
account: account,
actor_group: actor_group,
client: client,
gateway_group: gateway_group,
gateway: gateway,
subject: subject
} do
resource =
Fixtures.Resources.create_resource(
account: account,
connections: [%{gateway_group_id: gateway_group.id}]
)
Fixtures.Policies.create_policy(
account: account,
actor_group: actor_group,
resource: resource,
conditions: [
%{
property: :remote_ip_location_region,
operator: :is_in,
values: [client.last_seen_remote_ip_location_region]
},
%{
property: :remote_ip,
operator: :is_in_cidr,
values: ["0.0.0.0/0", "0::/0"]
}
]
)
assert {:ok, _fetched_resource, _flow} =
authorize_flow(client, gateway, resource.id, subject)
end
test "creates a network flow for users", %{
@@ -169,14 +313,17 @@ defmodule Domain.FlowsTest do
other_client = Fixtures.Clients.create_client()
other_gateway = Fixtures.Gateways.create_gateway()
assert authorize_flow(client, gateway, resource.id, other_subject) ==
{:error, :internal_error}
assert_raise FunctionClauseError, fn ->
assert authorize_flow(client, gateway, resource.id, other_subject)
end
assert authorize_flow(client, other_gateway, resource.id, subject) ==
{:error, :internal_error}
assert_raise FunctionClauseError, fn ->
assert authorize_flow(client, other_gateway, resource.id, subject)
end
assert authorize_flow(other_client, gateway, resource.id, subject) ==
{:error, :internal_error}
assert_raise FunctionClauseError, fn ->
assert authorize_flow(other_client, gateway, resource.id, subject)
end
end
test "returns error when subject has no permission to create flows", %{

View File

@@ -0,0 +1,480 @@
defmodule Domain.Policies.Condition.EvaluatorTest do
use Domain.DataCase, async: true
import Domain.Policies.Condition.Evaluator
describe "ensure_conforms/2" do
test "returns ok when there are no conditions" do
client = %Domain.Clients.Client{}
assert ensure_conforms([], client) == :ok
end
test "returns ok when all conditions are met" do
client = %Domain.Clients.Client{
last_seen_remote_ip_location_region: "US",
last_seen_remote_ip: %Postgrex.INET{address: {192, 168, 0, 1}}
}
conditions = [
%Domain.Policies.Condition{
property: :remote_ip_location_region,
operator: :is_in,
values: ["US"]
},
%Domain.Policies.Condition{
property: :remote_ip,
operator: :is_in_cidr,
values: ["192.168.0.1/24"]
}
]
assert ensure_conforms(conditions, client) == :ok
end
test "returns error when all conditions are not met" do
client = %Domain.Clients.Client{
last_seen_remote_ip_location_region: "US",
last_seen_remote_ip: %Postgrex.INET{address: {192, 168, 0, 1}}
}
conditions = [
%Domain.Policies.Condition{
property: :remote_ip_location_region,
operator: :is_in,
values: ["CN"]
},
%Domain.Policies.Condition{
property: :remote_ip,
operator: :is_in_cidr,
values: ["10.10.0.1/24"]
}
]
assert ensure_conforms(conditions, client) ==
{:error, [:remote_ip_location_region, :remote_ip]}
end
test "returns error when one of the conditions is not met" do
client = %Domain.Clients.Client{
last_seen_remote_ip_location_region: "US",
last_seen_remote_ip: %Postgrex.INET{address: {192, 168, 0, 1}}
}
conditions = [
%Domain.Policies.Condition{
property: :remote_ip_location_region,
operator: :is_in,
values: ["CN"]
},
%Domain.Policies.Condition{
property: :remote_ip,
operator: :is_in_cidr,
values: ["192.168.0.1/24"]
}
]
assert ensure_conforms(conditions, client) ==
{:error, [:remote_ip_location_region]}
end
end
describe "conforms?/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,
values: ["US"]
}
client = %Domain.Clients.Client{
last_seen_remote_ip_location_region: "US"
}
assert conforms?(%{condition | operator: :is_in}, client) == true
assert conforms?(%{condition | operator: :is_not_in}, client) == false
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
end
test "when client last seen remote ip is in or not in the CIDR values" do
condition = %Domain.Policies.Condition{
property: :remote_ip,
values: ["192.168.0.1/24"]
}
client = %Domain.Clients.Client{
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
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
end
test "when client last seen remote ip is in or not in the IP values" do
condition = %Domain.Policies.Condition{
property: :remote_ip,
values: ["192.168.0.1", "2001:0000:130F:0000:0000:09C0:876A:130B"]
}
client = %Domain.Clients.Client{
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
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
end
test "when client identity provider id is in or not in the values" do
condition = %Domain.Policies.Condition{
property: :provider_id,
values: ["00000000-0000-0000-0000-000000000000"]
}
client = %Domain.Clients.Client{
identity: %Domain.Auth.Identity{
provider_id: "00000000-0000-0000-0000-000000000000"
}
}
assert conforms?(%{condition | operator: :is_in}, client) == true
assert conforms?(%{condition | operator: :is_not_in}, client) == false
client = %Domain.Clients.Client{
identity: %Domain.Auth.Identity{
provider_id: "11111111-1111-1111-1111-111111111111"
}
}
assert conforms?(%{condition | operator: :is_in}, client) == false
assert conforms?(%{condition | operator: :is_not_in}, client) == true
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
condition = %Domain.Policies.Condition{
property: :current_utc_datetime,
values: []
}
client = %Domain.Clients.Client{}
assert conforms?(%{condition | operator: :is_in_day_of_week_time_ranges}, client) == false
end
end
describe "datetime_in_day_of_the_week_time_ranges?/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]
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
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
dow_time_ranges = ["F/true/UTC"]
assert datetime_in_day_of_the_week_time_ranges?(datetime, dow_time_ranges) == true
end
test "returns false when datetime is not in the day of the week time ranges" do
# Friday
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
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
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
dow_time_ranges = ["U/true/UTC"]
assert datetime_in_day_of_the_week_time_ranges?(datetime, dow_time_ranges) == false
end
test "handles different timezones" do
# Friday in UTC, Thursday in US/Pacific (UTC-8)
datetime = ~U[2021-01-01 01:00:00Z]
dow_time_ranges = ["R/true/US/Pacific"]
assert datetime_in_day_of_the_week_time_ranges?(datetime, dow_time_ranges) == true
dow_time_ranges = ["F/true/UTC"]
assert datetime_in_day_of_the_week_time_ranges?(datetime, dow_time_ranges) == true
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
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
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
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
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
dow_time_ranges = ["F/false/UTC"]
assert datetime_in_day_of_the_week_time_ranges?(datetime, dow_time_ranges) == false
dow_time_ranges = ["F/10-11"]
assert datetime_in_day_of_the_week_time_ranges?(datetime, dow_time_ranges) == false
dow_time_ranges = ["F/10-11/"]
assert datetime_in_day_of_the_week_time_ranges?(datetime, dow_time_ranges) == false
end
end
describe "parse_days_of_week_time_ranges/1" do
test "parses list of days of the week time ranges" do
assert parse_days_of_week_time_ranges(["M/true/UTC"]) ==
{:ok, %{"M" => [{~T[00:00:00], ~T[23:59:59], "UTC"}]}}
assert parse_days_of_week_time_ranges(["M/true/UTC", "W/19:00:00-22:00:00,22-23/US/Pacific"]) ==
{:ok,
%{
"M" => [
{~T[00:00:00], ~T[23:59:59], "UTC"}
],
"W" => [
{~T[19:00:00], ~T[22:00:00], "US/Pacific"},
{~T[22:00:00], ~T[23:00:00], "US/Pacific"}
]
}}
end
test "merges list of days of the week time ranges" do
assert parse_days_of_week_time_ranges([
"M/true,10:00:00-11:00:00/UTC",
"W/19:00:00-22:00:00/US/Pacific"
]) ==
{:ok,
%{
"M" => [
{~T[00:00:00], ~T[23:59:59], "UTC"},
{~T[10:00:00], ~T[11:00:00], "UTC"}
],
"W" => [
{~T[19:00:00], ~T[22:00:00], "US/Pacific"}
]
}}
assert parse_days_of_week_time_ranges([
"M/true/UTC",
"W/19:00:00-22:00:00/UTC",
"M/10:00:00-11:00:00/UTC"
]) ==
{:ok,
%{
"M" => [
{~T[00:00:00], ~T[23:59:59], "UTC"},
{~T[10:00:00], ~T[11:00:00], "UTC"}
],
"W" => [
{~T[19:00:00], ~T[22:00:00], "UTC"}
]
}}
assert parse_days_of_week_time_ranges([
"M/22:00:00-22:30/UTC",
"M/19-22:00:00/UTC",
"M/true/UTC"
]) ==
{:ok,
%{
"M" => [
{~T[22:00:00], ~T[22:30:00], "UTC"},
{~T[19:00:00], ~T[22:00:00], "UTC"},
{~T[00:00:00], ~T[23:59:59], "UTC"}
]
}}
assert parse_days_of_week_time_ranges([
"M/09:00:00-10:00:00/UTC",
"W/19:00:00-22:00:00/UTC",
"M/10:00:00-11:00:00/UTC"
]) ==
{:ok,
%{
"M" => [
{~T[09:00:00], ~T[10:00:00], "UTC"},
{~T[10:00:00], ~T[11:00:00], "UTC"}
],
"W" => [
{~T[19:00:00], ~T[22:00:00], "UTC"}
]
}}
end
test "returns error on invalid timezone" do
assert parse_days_of_week_time_ranges(["M/true"]) ==
{:error, "timezone is required"}
assert parse_days_of_week_time_ranges(["M/true/invalid"]) ==
{:error, "invalid timezone"}
end
end
describe "parse_day_of_week_time_ranges/1" do
test "parses 7 days of the week" do
for day <- ~w[M T W R F S U] do
assert {:ok,
{^day,
[
{~T[00:00:00], ~T[23:59:59], "US/Pacific"}
]}} = parse_day_of_week_time_ranges("#{day}/true/US/Pacific")
end
end
test "parses day of week time ranges" do
assert parse_day_of_week_time_ranges("M/08:00:00-17:00:00,22:00:00-23:59:59/America/Merida") ==
{:ok,
{"M",
[
{~T[08:00:00], ~T[17:00:00], "America/Merida"},
{~T[22:00:00], ~T[23:59:59], "America/Merida"}
]}}
assert parse_day_of_week_time_ranges("U/08:00:00-17:00:00/UTC") ==
{:ok, {"U", [{~T[08:00:00], ~T[17:00:00], "UTC"}]}}
assert parse_day_of_week_time_ranges("U/08:00-17:00:00/US/Pacific") ==
{:ok, {"U", [{~T[08:00:00], ~T[17:00:00], "US/Pacific"}]}}
end
test "returns error when invalid day of week is provided" do
assert parse_day_of_week_time_ranges("X/08:00:00-17:00:00/UTC") ==
{:error, "invalid day of the week, must be one of M, T, W, R, F, S, U"}
end
test "returns error when invalid time range is provided" do
assert parse_day_of_week_time_ranges("M/08:00:00-17:00:00-/UTC") ==
{:error, "invalid time range: 08:00:00-17:00:00-"}
end
test "returns error when invalid time is provided" do
assert parse_day_of_week_time_ranges("M/25-17:00:00/UTC") ==
{:error, "invalid time range: 25-17:00:00"}
assert parse_day_of_week_time_ranges("M/08:00:00-25/UTC") ==
{:error, "invalid time range: 08:00:00-25"}
end
test "returns error when start of the time range is greater than the end of it" do
assert {:error, "start of the time range must be less than or equal to the end of it"} =
parse_day_of_week_time_ranges("M/17:00:00-08:00:00/UTC")
end
end
describe "parse_time_ranges/1" do
test "parses time ranges" do
assert parse_time_ranges("true") ==
{:ok, [{~T[00:00:00], ~T[23:59:59]}]}
assert parse_time_ranges("08:00:00-17:00:00") ==
{:ok, [{~T[08:00:00], ~T[17:00:00]}]}
assert parse_time_ranges("08:00-17:00:00") ==
{:ok, [{~T[08:00:00], ~T[17:00:00]}]}
assert parse_time_ranges("08:00:00-17:00") ==
{:ok, [{~T[08:00:00], ~T[17:00:00]}]}
assert parse_time_ranges("08-17:00:00") ==
{:ok, [{~T[08:00:00], ~T[17:00:00]}]}
assert parse_time_ranges("08:00:00-17") ==
{:ok, [{~T[08:00:00], ~T[17:00:00]}]}
assert parse_time_ranges("08:00:00-17:00:00,09:00:00-10:00:00") ==
{:ok, [{~T[08:00:00], ~T[17:00:00]}, {~T[09:00:00], ~T[10:00:00]}]}
end
test "returns error when invalid time range is provided" do
assert parse_time_ranges("08:00:00-17:00:00-") ==
{:error, "invalid time range: 08:00:00-17:00:00-"}
end
test "returns error when invalid time is provided" do
assert parse_time_ranges("25:00:00-17:00:00") ==
{:error, "invalid time range: 25:00:00-17:00:00"}
assert parse_time_ranges("08:00:00-25:00:00") ==
{:error, "invalid time range: 08:00:00-25:00:00"}
assert parse_time_ranges("25-17:00:00") ==
{:error, "invalid time range: 25-17:00:00"}
assert parse_time_ranges("08:00:00-25") ==
{:error, "invalid time range: 08:00:00-25"}
end
test "returns error when start of the time range is greater than the end of it" do
assert {:error, "start of the time range must be less than or equal to the end of it"} =
parse_time_ranges("17:00:00-08:00:00")
end
end
describe "parse_time_range/1" do
test "parses time range" do
assert parse_time_range("08:00:00-17:00:00") ==
{:ok, {~T[08:00:00], ~T[17:00:00]}}
assert parse_time_range("08:00-17:00:00") ==
{:ok, {~T[08:00:00], ~T[17:00:00]}}
assert parse_time_range("08:00:00-17:00") ==
{:ok, {~T[08:00:00], ~T[17:00:00]}}
assert parse_time_range("true") ==
{:ok, {~T[00:00:00], ~T[23:59:59]}}
end
test "returns error when invalid time range is provided" do
assert parse_time_range("08:00:00-17:00:00-") ==
{:error, "invalid time range: 08:00:00-17:00:00-"}
end
test "returns error when invalid time is provided" do
assert parse_time_range("25-17:00:00") ==
{:error, "invalid time range: 25-17:00:00"}
assert parse_time_range("08:00:00-33") ==
{:error, "invalid time range: 08:00:00-33"}
end
test "returns error when start of the time range is greater than the end of it" do
assert {:error, "start of the time range must be less than or equal to the end of it"} =
parse_time_range("17:00:00-08:00:00")
end
end
end

View File

@@ -1,5 +1,4 @@
defmodule Domain.PoliciesTest do
alias Web.Policies
use Domain.DataCase, async: true
import Domain.Policies
alias Domain.Policies
@@ -232,6 +231,74 @@ defmodule Domain.PoliciesTest do
assert policy.resource_id == resource.id
end
test "creates a policy with conditions", %{
account: account,
subject: subject
} do
resource = Fixtures.Resources.create_resource(account: account)
actor_group = Fixtures.Actors.create_group(account: account)
attrs = %{
actor_group_id: actor_group.id,
resource_id: resource.id,
conditions: [
%{
property: :remote_ip,
operator: :is_in_cidr,
values: ["10.10.0.0/24"]
},
%{
property: :remote_ip_location_region,
operator: :is_in,
values: ["US"]
},
%{
property: :provider_id,
operator: :is_not_in,
values: ["3c712b5d-b1af-4b5a-9f33-aa3d1a4dc296"]
},
%{
property: :current_utc_datetime,
operator: :is_in_day_of_week_time_ranges,
values: [
"M/13:00:00-15:00:00,19:00:00-22:00:00/Poland",
"F/08:00:00-20:00:00/UTC",
"S/true/US/Pacific"
]
}
]
}
assert {:ok, policy} = create_policy(attrs, subject)
assert policy.conditions == [
%Policies.Condition{
property: :remote_ip,
operator: :is_in_cidr,
values: ["10.10.0.0/24"]
},
%Policies.Condition{
property: :remote_ip_location_region,
operator: :is_in,
values: ["US"]
},
%Policies.Condition{
property: :provider_id,
operator: :is_not_in,
values: ["3c712b5d-b1af-4b5a-9f33-aa3d1a4dc296"]
},
%Policies.Condition{
property: :current_utc_datetime,
operator: :is_in_day_of_week_time_ranges,
values: [
"M/13:00:00-15:00:00,19:00:00-22:00:00/Poland",
"F/08:00:00-20:00:00/UTC",
"S/true/US/Pacific"
]
}
]
end
test "broadcasts an account message when policy is created", %{
account: account,
subject: subject
@@ -895,4 +962,43 @@ defmodule Domain.PoliciesTest do
assert delete_policies_for(resource, subject) == {:ok, []}
end
end
describe "ensure_client_conforms_policy_conditions/2" do
test "returns :ok when client conforms to policy conditions", %{} do
client = %Domain.Clients.Client{
last_seen_remote_ip_location_region: "US"
}
policy = %Policies.Policy{
conditions: [
%Policies.Condition{
property: :remote_ip_location_region,
operator: :is_in,
values: ["US"]
}
]
}
assert ensure_client_conforms_policy_conditions(client, policy) == :ok
end
test "returns error when client conforms to policy conditions", %{} do
client = %Domain.Clients.Client{
last_seen_remote_ip_location_region: "US"
}
policy = %Policies.Policy{
conditions: [
%Policies.Condition{
property: :remote_ip_location_region,
operator: :is_in,
values: ["CA"]
}
]
}
assert ensure_client_conforms_policy_conditions(client, policy) ==
{:error, {:forbidden, [violated_properties: [:remote_ip_location_region]]}}
end
end
end

View File

@@ -32,7 +32,6 @@ defmodule Domain.ResourcesTest do
assert {:ok, fetched_resource} = fetch_resource_by_id(resource.id, subject)
assert fetched_resource.id == resource.id
assert is_nil(fetched_resource.authorized_by_policy)
end
test "returns authorized resource for account user", %{
@@ -58,7 +57,7 @@ defmodule Domain.ResourcesTest do
assert {:ok, fetched_resource} = fetch_resource_by_id(resource.id, subject)
assert fetched_resource.id == resource.id
assert fetched_resource.authorized_by_policy.id == policy.id
assert Enum.map(fetched_resource.authorized_by_policies, & &1.id) == [policy.id]
end
test "returns deleted resources", %{account: account, subject: subject} do
@@ -135,7 +134,7 @@ defmodule Domain.ResourcesTest do
assert {:ok, fetched_resource} = fetch_and_authorize_resource_by_id(resource.id, subject)
assert fetched_resource.id == resource.id
assert fetched_resource.authorized_by_policy.id == policy.id
assert Enum.map(fetched_resource.authorized_by_policies, & &1.id) == [policy.id]
end
test "returns authorized resource for account user", %{
@@ -161,7 +160,7 @@ defmodule Domain.ResourcesTest do
assert {:ok, fetched_resource} = fetch_and_authorize_resource_by_id(resource.id, subject)
assert fetched_resource.id == resource.id
assert fetched_resource.authorized_by_policy.id == policy.id
assert Enum.map(fetched_resource.authorized_by_policies, & &1.id) == [policy.id]
end
test "returns authorized resource using one of multiple policies for account user", %{
@@ -194,7 +193,10 @@ defmodule Domain.ResourcesTest do
assert {:ok, fetched_resource} = fetch_and_authorize_resource_by_id(resource.id, subject)
assert fetched_resource.id == resource.id
assert fetched_resource.authorized_by_policy.id in [policy1.id, policy2.id]
authorized_by_policy_ids = Enum.map(fetched_resource.authorized_by_policies, & &1.id)
policy_ids = [policy1.id, policy2.id]
assert Enum.sort(authorized_by_policy_ids) == Enum.sort(policy_ids)
end
test "does not return deleted resources", %{account: account, actor: actor, subject: subject} do
@@ -418,7 +420,7 @@ defmodule Domain.ResourcesTest do
assert {:ok, resources} = all_authorized_resources(subject)
assert length(resources) == 1
assert hd(resources).authorized_by_policy.id == policy.id
assert Enum.map(hd(resources).authorized_by_policies, & &1.id) == [policy.id]
policy2 =
Fixtures.Policies.create_policy(
@@ -430,7 +432,7 @@ defmodule Domain.ResourcesTest do
assert {:ok, resources2} = all_authorized_resources(subject)
assert length(resources2) == 2
assert hd(resources2 -- resources).authorized_by_policy.id == policy2.id
assert hd(hd(resources2 -- resources).authorized_by_policies).id == policy2.id
end
test "returns authorized resources for account admin subject", %{
@@ -472,7 +474,7 @@ defmodule Domain.ResourcesTest do
assert {:ok, resources} = all_authorized_resources(subject)
assert length(resources) == 1
assert hd(resources).authorized_by_policy.id == policy.id
assert Enum.map(hd(resources).authorized_by_policies, & &1.id) == [policy.id]
Fixtures.Policies.create_policy(
account: account,

View File

@@ -19,6 +19,7 @@ defmodule Domain.Fixtures.Accounts do
},
features: %{
flow_activities: true,
policy_conditions: true,
multi_site_resources: true,
traffic_filters: true,
self_hosted_relays: true,

View File

@@ -1158,6 +1158,12 @@ defmodule Web.CoreComponents do
"""
end
def feature_name(%{feature: :policy_conditions} = assigns) do
~H"""
Define Policy Conditions
"""
end
def feature_name(%{feature: :multi_site_resources} = assigns) do
~H"""
Define globally-distributed resources

View File

@@ -39,6 +39,7 @@ defmodule Web.FormComponents do
doc: "a form field struct retrieved from the form, for example: @form[:email]"
attr :errors, :list, default: []
attr :value_index, :integer, default: nil
attr :inline_errors, :boolean,
default: false,
@@ -58,9 +59,20 @@ defmodule Web.FormComponents do
slot :inner_block
def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
errors =
if assigns.value_index do
Enum.filter(field.errors, fn {_error, meta} ->
Keyword.get(meta, :validated_as) == :list and
Keyword.get(meta, :at) == assigns.value_index
end)
else
field.errors
end
|> Enum.map(&translate_error(&1))
assigns
|> assign(field: nil, id: assigns.id || field.id)
|> assign(:errors, Enum.map(field.errors, &translate_error(&1)))
|> assign(:errors, errors)
|> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
|> assign_new(:value, fn ->
if assigns.value_id do
@@ -144,7 +156,7 @@ defmodule Web.FormComponents do
<div phx-feedback-for={@name}>
<.label :if={@label} for={@id}><%= @label %></.label>
<input
:if={not is_nil(@value) and not is_nil(@rest[:disabled])}
:if={not is_nil(@value) and @rest[:disabled] == true}
type="hidden"
name={@name}
value={@value}
@@ -185,7 +197,7 @@ defmodule Web.FormComponents do
<div phx-feedback-for={@name}>
<.label for={@id}><%= @label %></.label>
<input
:if={not is_nil(@value) and not is_nil(@rest[:disabled])}
:if={@rest[:disabled] in [true, "true"] and not is_nil(@value)}
type="hidden"
name={@name}
value={@value}
@@ -542,7 +554,7 @@ defmodule Web.FormComponents do
[text[size], spacing[size]]
end
defp icon_size(size) do
def icon_size(size) do
icon_size = %{
"xs" => "w-3 h-3",
"sm" => "w-3 h-3",

View File

@@ -405,9 +405,9 @@ defmodule Web.Actors.Show do
</.section>
<.section>
<:title>Activity</:title>
<:title>Authorized Activity</:title>
<:help>
Attempts to access resources by this actor.
Authorized attempts by actors to access the resource governed by this policy.
</:help>
<:content>
<.live_table

View File

@@ -156,9 +156,9 @@ defmodule Web.Clients.Show do
</.section>
<.section>
<:title>Activity</:title>
<:title>Authorized Activity</:title>
<:help>
Attempts by the actor using this client to access resources.
Authorized attempts by actors to access the resource governed by this policy.
</:help>
<:content>
<.live_table

View File

@@ -1,9 +1,619 @@
defmodule Web.Policies.Components do
use Web, :component_library
alias Domain.Policies
@days_of_week [
{"M", "Monday"},
{"T", "Tuesday"},
{"W", "Wednesday"},
{"R", "Thursday"},
{"F", "Friday"},
{"S", "Saturday"},
{"U", "Sunday"}
]
attr :policy, :map, required: true
def policy_name(assigns) do
~H"<%= @policy.actor_group.name %> → <%= @policy.resource.name %>"
end
def map_condition_params(attrs, opts) do
Map.update(attrs, "conditions", %{}, fn conditions ->
for {property, condition_attrs} <- conditions,
maybe_filter(condition_attrs, opts),
condition_attrs = map_condition_values(condition_attrs),
into: %{} do
{property, condition_attrs}
end
end)
end
defp maybe_filter(%{"values" => values}, empty_values: :drop) when is_list(values) do
not (values
|> List.wrap()
|> Enum.reject(fn value -> value in [nil, ""] end)
|> Enum.empty?())
end
defp maybe_filter(%{"values" => values}, empty_values: :drop) when is_map(values) do
not (values
|> Enum.reject(fn {_key, value} -> value in [nil, ""] end)
|> Enum.empty?())
end
defp maybe_filter(_condition_attrs, _opts) do
true
end
defp map_condition_values(
%{
"operator" => "is_in_day_of_week_time_ranges",
"timezone" => timezone
} = condition_attrs
) do
Map.update(condition_attrs, "values", [], fn values ->
values
|> Enum.sort_by(fn {dow, _} -> day_of_week_index(dow) end)
|> Enum.map(fn {dow, time_ranges} ->
"#{dow}/#{time_ranges}/#{timezone}"
end)
end)
end
defp map_condition_values(condition_attrs) do
condition_attrs
end
defp condition_values_empty?(%{
params: %{
"operator" => "is_in_day_of_week_time_ranges",
"values" => values
}
}) do
values
|> Enum.reject(fn value ->
case String.split(value, "/") do
[_, ranges, _] -> ranges == ""
_ -> true
end
end)
|> Enum.empty?()
end
defp condition_values_empty?(%{
params: %{"values" => values}
}) do
values
|> List.wrap()
|> Enum.reject(fn value -> value in [nil, ""] end)
|> Enum.empty?()
end
defp condition_values_empty?(%{}) do
true
end
def conditions(assigns) do
~H"""
<span :if={@conditions == []} class="text-neutral-500">
There are no conditions defined for this policy.
</span>
<span :if={@conditions != []} class="flex flex-wrap">
<span class="mr-1">This policy can be used</span>
<.condition
:for={condition <- @conditions}
providers={@providers}
property={condition.property}
operator={condition.operator}
values={condition.values}
/>
</span>
"""
end
defp condition(%{property: :remote_ip_location_region} = assigns) do
~H"""
<span :if={@values != []} class="mr-1">
<span :if={@operator == :is_in}>from</span>
<span :if={@operator == :is_not_in}>from any counties except</span>
<span class="font-medium">
<%= @values |> Enum.map(&Domain.Geo.country_common_name!/1) |> Enum.join(", ") %>
</span>
</span>
"""
end
defp condition(%{property: :remote_ip} = assigns) do
~H"""
<span :if={@values != []} class="mr-1">
<span>from IP addresses that are</span> <span :if={@operator == :is_in_cidr}>in</span>
<span :if={@operator == :is_not_in_cidr}>not in</span>
<span class="font-medium"><%= Enum.join(@values, ", ") %></span>
</span>
"""
end
defp condition(%{property: :provider_id} = assigns) do
assigns =
assign(
assigns,
:providers,
assigns.values
|> Enum.map(fn provider_id ->
Enum.find(assigns.providers, fn provider ->
provider.id == provider_id
end)
end)
|> Enum.reject(&is_nil/1)
)
~H"""
<span :if={@providers != []} class="flex flex-wrap space-x-1 mr-1">
<span>when signed in</span>
<span :if={@operator == :is_in}>with</span>
<span :if={@operator == :is_not_in}>not with</span>
<.intersperse_blocks>
<:separator>,</:separator>
<:item :for={provider <- @providers}>
<.link navigate={"/providers/#{provider.id}"} class={[link_style(), "font-medium"]}>
<%= provider.name %>
</.link>
</:item>
</.intersperse_blocks>
<span>provider(s)</span>
</span>
"""
end
defp condition(%{property: :current_utc_datetime, values: values} = assigns) do
assigns =
assign_new(assigns, :tz_time_ranges_by_dow, fn ->
{:ok, ranges} = Policies.Condition.Evaluator.parse_days_of_week_time_ranges(values)
ranges
|> Enum.reject(fn {_dow, time_ranges} -> time_ranges == [] end)
|> Enum.map(fn {dow, time_ranges} ->
time_ranges_by_timezone =
time_ranges
|> Enum.reduce(%{}, fn {starts_at, ends_at, timezone}, acc ->
range = {starts_at, ends_at}
Map.update(acc, timezone, [range], fn ranges -> [range | ranges] end)
end)
{dow, time_ranges_by_timezone}
end)
|> Enum.sort_by(fn {dow, _time_ranges_by_timezone} -> day_of_week_index(dow) end)
end)
~H"""
<span class="flex flex-wrap space-x-1 mr-1">
on
<.intersperse_blocks>
<:separator>,</:separator>
<:item :for={{day_of_week, tz_time_ranges} <- @tz_time_ranges_by_dow}>
<span class="ml-1 font-medium">
<%= day_of_week_name(day_of_week) <> "s" %>
<span :for={{timezone, time_ranges} <- tz_time_ranges}>
<%= "(" <>
Enum.map_join(time_ranges, ", ", fn {from, to} ->
"#{from} - #{to}"
end) <> " #{timezone})" %>
</span>
</span>
</:item>
</.intersperse_blocks>
</span>
"""
end
for {code, name} <- @days_of_week do
defp day_of_week_name(unquote(code)), do: unquote(name)
end
for {{code, _name}, index} <- Enum.with_index(@days_of_week) do
def day_of_week_index(unquote(code)), do: unquote(index)
end
defp condition_operator_option_name(:contains), do: "contains"
defp condition_operator_option_name(:does_not_contain), do: "does not contain"
defp condition_operator_option_name(:is_in), do: "is in"
defp condition_operator_option_name(:is_not_in), do: "is not in"
defp condition_operator_option_name(:is_in_day_of_week_time_ranges), do: ""
defp condition_operator_option_name(:is_in_cidr), do: "is in"
defp condition_operator_option_name(:is_not_in_cidr), do: "is not in"
def condition_form(assigns) do
assigns =
assign_new(assigns, :policy_conditions_enabled?, fn ->
Domain.Accounts.policy_conditions_enabled?(assigns.account)
end)
~H"""
<fieldset class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<legend class="text-lg mb-4">Conditions</legend>
<%= if @policy_conditions_enabled? == false do %>
<.link navigate={~p"/#{@account}/settings/billing"} class="text-sm text-primary-500">
<.badge type="primary" title="Feature available on a higher pricing plan">
<.icon name="hero-lock-closed" class="w-3.5 h-3.5 mr-1" /> UPGRADE TO UNLOCK
</.badge>
</.link>
<% end %>
</div>
<.remote_ip_location_region_condition_form
form={@form}
disabled={@policy_conditions_enabled? == false}
/>
<.remote_ip_condition_form form={@form} disabled={@policy_conditions_enabled? == false} />
<.provider_id_condition_form
form={@form}
providers={@providers}
disabled={@policy_conditions_enabled? == false}
/>
<.current_utc_datetime_condition_form
form={@form}
timezone={@timezone}
disabled={@policy_conditions_enabled? == false}
/>
</fieldset>
"""
end
defp remote_ip_location_region_condition_form(assigns) do
~H"""
<fieldset class="mb-2">
<% condition_form = find_condition_form(@form[:conditions], :remote_ip_location_region) %>
<.input
type="hidden"
field={condition_form[:property]}
name="policy[conditions][remote_ip_location_region][property]"
id="policy_conditions_remote_ip_location_region_property"
value="remote_ip_location_region"
/>
<div
class="cursor-pointer"
phx-click={
JS.toggle_class("hidden",
to: "#policy_conditions_remote_ip_location_region_condition"
)
|> JS.toggle_class("hero-chevron-down",
to: "#policy_conditions_remote_ip_location_region_chevron"
)
|> JS.toggle_class("hero-chevron-up",
to: "#policy_conditions_remote_ip_location_region_chevron"
)
}
>
<legend>
<.icon id="policy_conditions_remote_ip_location_region_chevron" name="hero-chevron-down" />
Client location
</legend>
<p class="text-sm text-neutral-500 mb-2">
Restrict access based on the location of the Client.
</p>
</div>
<div
id="policy_conditions_remote_ip_location_region_condition"
class={[
"grid gap-2 sm:grid-cols-5 sm:gap-4",
condition_values_empty?(condition_form) && "hidden"
]}
>
<.input
type="select"
name="policy[conditions][remote_ip_location_region][operator]"
id="policy_conditions_remote_ip_location_region_operator"
field={condition_form[:operator]}
disabled={@disabled}
options={condition_operator_options(:remote_ip_location_region)}
value={get_in(condition_form, [:operator, Access.key!(:value)])}
/>
<%= for {value, index} <- Enum.with_index((condition_form[:values] && condition_form[:values].value || []) ++ [nil]) do %>
<div :if={index > 0} class="text-right mt-3 text-sm text-neutral-900">
or
</div>
<div class="col-span-4">
<.input
type="select"
field={condition_form[:values]}
name="policy[conditions][remote_ip_location_region][values][]"
id={"policy_conditions_remote_ip_location_region_values_#{index}"}
options={[{"Select Country", nil}] ++ Domain.Geo.all_country_options!()}
disabled={@disabled}
value_index={index}
value={value}
/>
</div>
<% end %>
</div>
</fieldset>
"""
end
defp remote_ip_condition_form(assigns) do
~H"""
<fieldset class="mb-2">
<% condition_form = find_condition_form(@form[:conditions], :remote_ip) %>
<.input
type="hidden"
field={condition_form[:property]}
name="policy[conditions][remote_ip][property]"
id="policy_conditions_remote_ip_property"
value="remote_ip"
/>
<div
class="cursor-pointer"
phx-click={
JS.toggle_class("hidden",
to: "#policy_conditions_remote_ip_condition"
)
|> JS.toggle_class("hero-chevron-down",
to: "#policy_conditions_remote_ip_chevron"
)
|> JS.toggle_class("hero-chevron-up",
to: "#policy_conditions_remote_ip_chevron"
)
}
>
<legend>
<.icon id="policy_conditions_remote_ip_chevron" name="hero-chevron-down" class="w-5 h-5" />
IP address
</legend>
<p class="text-sm text-neutral-500 mb-2">
Restrict access based on the Client's IP address or CIDR range.
</p>
</div>
<div
id="policy_conditions_remote_ip_condition"
class={[
"grid gap-2 sm:grid-cols-5 sm:gap-4",
condition_values_empty?(condition_form) && "hidden"
]}
>
<.input
type="select"
name="policy[conditions][remote_ip][operator]"
id="policy_conditions_remote_ip_operator"
field={condition_form[:operator]}
options={condition_operator_options(:remote_ip)}
disabled={@disabled}
value={get_in(condition_form, [:operator, Access.key!(:value)])}
/>
<%= for {value, index} <- Enum.with_index((condition_form[:values] && condition_form[:values].value || []) ++ [nil]) do %>
<div :if={index > 0} class="text-right mt-3 text-sm text-neutral-900">
or
</div>
<div class="col-span-4">
<.input
type="text"
field={condition_form[:values]}
name="policy[conditions][remote_ip][values][]"
id={"policy_conditions_remote_ip_values_#{index}"}
placeholder="E.g. 189.172.0.0/24 or 10.10.10.1"
disabled={@disabled}
value_index={index}
value={value}
/>
</div>
<% end %>
</div>
</fieldset>
"""
end
defp provider_id_condition_form(assigns) do
~H"""
<fieldset class="mb-2">
<% condition_form = find_condition_form(@form[:conditions], :provider_id) %>
<.input
type="hidden"
field={condition_form[:property]}
name="policy[conditions][provider_id][property]"
id="policy_conditions_provider_id_property"
value="provider_id"
/>
<div
class="cursor-pointer"
phx-click={
JS.toggle_class("hidden",
to: "#policy_conditions_provider_id_condition"
)
|> JS.toggle_class("hero-chevron-down",
to: "#policy_conditions_provider_id_chevron"
)
|> JS.toggle_class("hero-chevron-up",
to: "#policy_conditions_provider_id_chevron"
)
}
>
<legend>
<.icon id="policy_conditions_provider_id_chevron" name="hero-chevron-down" class="w-5 h-5" />
Authentication Provider
</legend>
<p class="text-sm text-neutral-500 mb-2">
Restrict access based on the identity provider that was used to sign in.
</p>
</div>
<div
id="policy_conditions_provider_id_condition"
class={[
"grid gap-2 sm:grid-cols-5 sm:gap-4",
condition_values_empty?(condition_form) && "hidden"
]}
>
<.input
type="select"
name="policy[conditions][provider_id][operator]"
id="policy_conditions_provider_id_operator"
field={condition_form[:operator]}
options={condition_operator_options(:provider_id)}
disabled={@disabled}
value={get_in(condition_form, [:operator, Access.key!(:value)])}
/>
<%= for {value, index} <- Enum.with_index((condition_form[:values] && condition_form[:values].value || []) ++ [nil]) do %>
<div :if={index > 0} class="text-right mt-3 text-sm text-neutral-900">
or
</div>
<div class="col-span-4">
<.input
type="select"
field={condition_form[:values]}
name="policy[conditions][provider_id][values][]"
id={"policy_conditions_provider_id_values_#{index}"}
options={[{"Select Provider", nil}] ++ Enum.map(@providers, &{&1.name, &1.id})}
disabled={@disabled}
value_index={index}
value={value}
/>
</div>
<% end %>
</div>
</fieldset>
"""
end
defp current_utc_datetime_condition_form(assigns) do
assigns = assign_new(assigns, :days_of_week, fn -> @days_of_week end)
~H"""
<fieldset>
<% 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"
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="cursor-pointer"
phx-click={
JS.toggle_class("hidden",
to: "#policy_conditions_current_utc_datetime_condition"
)
|> 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>
<.icon
id="policy_conditions_current_utc_datetime_chevron"
name="hero-chevron-down"
class="w-5 h-5"
/> Current time
</legend>
<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>
<div
id="policy_conditions_current_utc_datetime_condition"
class={[
"space-y-2",
condition_values_empty?(condition_form) && "hidden"
]}
>
<.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>
</fieldset>
"""
end
defp find_condition_form(form_field, property) do
condition_form =
form_field.value
|> Enum.find_value(fn condition ->
if Ecto.Changeset.get_field(condition, :property) == property do
to_form(condition)
end
end)
condition_form || to_form(%{})
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
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)
|> Enum.map(&{condition_operator_option_name(&1), &1})
end
end

View File

@@ -1,22 +1,26 @@
defmodule Web.Policies.New do
use Web, :live_view
alias Domain.{Resources, Actors, Policies}
import Web.Policies.Components
alias Domain.{Resources, Actors, Policies, Auth}
def mount(params, _session, socket) do
# TODO: unify this dropdown and the one we use for live table filters
resources = Resources.all_resources!(socket.assigns.subject, preload: [:gateway_groups])
# TODO: unify this dropdown and the one we use for live table filters
actor_groups = Actors.all_groups!(socket.assigns.subject, preload: :provider)
providers = Auth.all_active_providers_for_account!(socket.assigns.account)
form = to_form(Policies.new_policy(%{}, socket.assigns.subject))
socket =
assign(socket,
resources: resources,
actor_groups: actor_groups,
providers: providers,
params: Map.take(params, ["site_id"]),
resource_id: params["resource_id"],
actor_group_id: params["actor_group_id"],
page_title: "New Policy",
timezone: Map.get(socket.private.connect_params, "timezone", "UTC"),
form: form
)
@@ -35,7 +39,7 @@ defmodule Web.Policies.New do
</:title>
<:content>
<div class="max-w-2xl px-4 py-8 mx-auto lg:py-16">
<h2 class="mb-4 text-xl text-neutral-900">Policy details</h2>
<h2 class="mb-4 text-xl text-neutral-900">Define a Policy</h2>
<div
:if={@actor_groups == []}
class={[
@@ -56,40 +60,50 @@ defmodule Web.Policies.New do
<.form :if={@actor_groups != []} for={@form} phx-submit="submit" phx-change="validate">
<.base_error form={@form} field={:base} />
<div class="grid gap-4 mb-4 sm:grid-cols-1 sm:gap-6 sm:mb-6">
<.input
field={@form[:actor_group_id]}
label="Group"
type="group_select"
options={Web.Groups.Components.select_options(@actor_groups)}
value={@actor_group_id || @form[:actor_group_id].value}
disabled={not is_nil(@actor_group_id)}
required
/>
<fieldset class="flex flex-col gap-2">
<.input
field={@form[:actor_group_id]}
label="Group"
type="group_select"
options={Web.Groups.Components.select_options(@actor_groups)}
value={@actor_group_id || @form[:actor_group_id].value}
disabled={not is_nil(@actor_group_id)}
required
/>
<.input
field={@form[:resource_id]}
label="Resource"
type="select"
options={
Enum.map(@resources, fn resource ->
group_names = resource.gateway_groups |> Enum.map(& &1.name)
<.input
field={@form[:resource_id]}
label="Resource"
type="select"
options={
Enum.map(@resources, fn resource ->
group_names = resource.gateway_groups |> Enum.map(& &1.name)
[
key: "#{resource.name} - #{Enum.join(group_names, ",")}",
value: resource.id
]
end)
}
value={@resource_id || @form[:resource_id].value}
disabled={not is_nil(@resource_id)}
required
/>
<.input
field={@form[:description]}
type="textarea"
label="Description"
placeholder="Enter a reason for creating a policy here"
phx-debounce="300"
[
key: "#{resource.name} - #{Enum.join(group_names, ",")}",
value: resource.id
]
end)
}
value={@resource_id || @form[:resource_id].value}
disabled={not is_nil(@resource_id)}
required
/>
<.input
field={@form[:description]}
label="Description"
type="textarea"
placeholder="Optionally, enter a reason for creating a policy here."
phx-debounce="300"
/>
</fieldset>
<.condition_form
form={@form}
account={@account}
timezone={@timezone}
providers={@providers}
/>
</div>
@@ -103,21 +117,24 @@ defmodule Web.Policies.New do
"""
end
def handle_event("validate", %{"policy" => policy_params}, socket) do
def handle_event("validate", %{"policy" => params}, socket) do
form =
policy_params
|> put_default_policy_params(socket)
params
|> put_default_params(socket)
|> map_condition_params(empty_values: :keep)
|> Policies.new_policy(socket.assigns.subject)
|> Map.put(:action, :validate)
|> to_form()
|> to_form(action: :validate)
{:noreply, assign(socket, form: form)}
end
def handle_event("submit", %{"policy" => policy_params}, socket) do
policy_params = put_default_policy_params(policy_params, socket)
def handle_event("submit", %{"policy" => params}, socket) do
params =
params
|> put_default_params(socket)
|> map_condition_params(empty_values: :drop)
with {:ok, policy} <- Policies.create_policy(policy_params, socket.assigns.subject) do
with {:ok, policy} <- Policies.create_policy(params, socket.assigns.subject) do
if site_id = socket.assigns.params["site_id"] do
{:noreply,
push_navigate(socket, to: ~p"/#{socket.assigns.account}/sites/#{site_id}?#resources")}
@@ -126,12 +143,11 @@ defmodule Web.Policies.New do
end
else
{:error, %Ecto.Changeset{} = changeset} ->
form = to_form(changeset)
{:noreply, assign(socket, form: form)}
{:noreply, assign(socket, form: to_form(changeset, action: :insert))}
end
end
defp put_default_policy_params(attrs, socket) do
defp put_default_params(attrs, socket) do
if resource_id = socket.assigns.resource_id do
Map.put(attrs, "resource_id", resource_id)
else

View File

@@ -1,13 +1,15 @@
defmodule Web.Policies.Show do
use Web, :live_view
import Web.Policies.Components
alias Domain.{Accounts, Policies, Flows}
alias Domain.{Accounts, Policies, Flows, Auth}
def mount(%{"id" => id}, _session, socket) do
with {:ok, policy} <-
Policies.fetch_policy_by_id(id, socket.assigns.subject,
preload: [actor_group: [:provider], resource: [], created_by_identity: :actor]
) do
providers = Auth.all_active_providers_for_account!(socket.assigns.account)
if connected?(socket) do
:ok = Policies.subscribe_to_events_for_policy(policy)
end
@@ -16,6 +18,7 @@ defmodule Web.Policies.Show do
assign(socket,
page_title: "Policy #{policy.id}",
policy: policy,
providers: providers,
flow_activities_enabled?: Accounts.flow_activities_enabled?(socket.assigns.account)
)
|> assign_live_table("flows",
@@ -126,7 +129,15 @@ defmodule Web.Policies.Show do
</span>
</:value>
</.vertical_table_row>
<.vertical_table_row>
<.vertical_table_row :if={@policy.conditions != []}>
<:label>
Conditions
</:label>
<:value>
<.conditions providers={@providers} conditions={@policy.conditions} />
</:value>
</.vertical_table_row>
<.vertical_table_row :if={@policy.description}>
<:label>
Description
</:label>
@@ -153,11 +164,9 @@ defmodule Web.Policies.Show do
</.section>
<.section>
<:title>
Activity
</:title>
<:title>Authorized Activity</:title>
<:help>
Attempts by actors to access the resource governed by this policy.
Authorized attempts by actors to access the resource governed by this policy.
</:help>
<:content>
<.live_table

View File

@@ -251,11 +251,9 @@ defmodule Web.Resources.Show do
</.section>
<.section>
<:title>
Activity
</:title>
<:title>Authorized Activity</:title>
<:help>
Attempts by actors to access this resource.
Authorized attempts by actors to access the resource governed by this policy.
</:help>
<:content>
<.live_table

View File

@@ -49,6 +49,8 @@ defmodule Web.MixProject do
# CLDR and unit conversions
{:ex_cldr_dates_times, "~> 2.13"},
{:ex_cldr_numbers, "~> 2.31"},
{:ex_cldr, "~> 2.38"},
{:tzdata, "~> 1.1"},
{:sizeable, "~> 1.0"},
# Asset pipeline deps

View File

@@ -60,6 +60,25 @@ 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][]",
"policy[conditions][remote_ip][operator]",
"policy[conditions][remote_ip][property]",
"policy[conditions][remote_ip][values][]",
"policy[conditions][remote_ip_location_region][operator]",
"policy[conditions][remote_ip_location_region][property]",
"policy[conditions][remote_ip_location_region][values][]",
"policy[description]",
"policy[resource_id]"
]
@@ -80,6 +99,25 @@ 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][]",
"policy[conditions][remote_ip][operator]",
"policy[conditions][remote_ip][property]",
"policy[conditions][remote_ip][values][]",
"policy[conditions][remote_ip_location_region][operator]",
"policy[conditions][remote_ip_location_region][property]",
"policy[conditions][remote_ip_location_region][values][]",
"policy[description]",
"policy[resource_id]"
]
@@ -111,6 +149,25 @@ 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][]",
"policy[conditions][remote_ip][operator]",
"policy[conditions][remote_ip][property]",
"policy[conditions][remote_ip][values][]",
"policy[conditions][remote_ip_location_region][operator]",
"policy[conditions][remote_ip_location_region][property]",
"policy[conditions][remote_ip_location_region][values][]",
"policy[description]",
"policy[resource_id]"
]
@@ -135,8 +192,7 @@ defmodule Web.Live.Policies.NewTest do
resource = Fixtures.Resources.create_resource(account: account)
attrs =
Fixtures.Policies.policy_attrs()
|> Map.take([:name])
%{}
|> Map.put(:actor_group_id, group.id)
|> Map.put(:resource_id, resource.id)
@@ -196,11 +252,10 @@ defmodule Web.Live.Policies.NewTest do
group = Fixtures.Actors.create_group(account: account)
resource = Fixtures.Resources.create_resource(account: account)
attrs =
Fixtures.Policies.policy_attrs()
|> Map.take([:name])
|> Map.put(:actor_group_id, group.id)
|> Map.put(:resource_id, resource.id)
attrs = %{
actor_group_id: group.id,
resource_id: resource.id
}
{:ok, lv, _html} =
conn
@@ -211,7 +266,97 @@ defmodule Web.Live.Policies.NewTest do
|> form("form", policy: attrs)
|> render_submit()
policy = Repo.get_by(Domain.Policies.Policy, attrs)
assert policy = Repo.get_by(Domain.Policies.Policy, attrs)
assert assert_redirect(lv, ~p"/#{account}/policies/#{policy}")
end
test "creates a new policy with conditions", %{
account: account,
identity: identity,
conn: conn
} do
group = Fixtures.Actors.create_group(account: account)
resource = Fixtures.Resources.create_resource(account: account)
attrs = %{
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",
values: [identity.provider_id]
},
remote_ip: %{
property: "remote_ip",
operator: "is_not_in_cidr",
values: ["0.0.0.0/0"]
},
remote_ip_location_region: %{
property: "remote_ip_location_region",
operator: "is_in",
values: ["US"]
}
}
}
{:ok, lv, _html} =
conn
|> authorize_conn(identity)
|> live(~p"/#{account}/policies/new?resource_id=#{resource}")
assert lv
|> form("form", policy: attrs)
|> render_submit()
policy = Repo.get_by(Domain.Policies.Policy, actor_group_id: group.id)
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,
values: [identity.provider_id]
},
%Domain.Policies.Condition{
property: :remote_ip,
operator: :is_not_in_cidr,
values: ["0.0.0.0/0"]
},
%Domain.Policies.Condition{
property: :remote_ip_location_region,
operator: :is_in,
values: ["US"]
}
]
assert assert_redirect(lv, ~p"/#{account}/policies/#{policy}")
end
@@ -225,9 +370,7 @@ defmodule Web.Live.Policies.NewTest do
resource = Fixtures.Resources.create_resource(account: account)
attrs =
Fixtures.Policies.policy_attrs()
|> Map.take([:name])
|> Map.put(:actor_group_id, group.id)
%{actor_group_id: group.id}
{:ok, lv, _html} =
conn
@@ -254,10 +397,7 @@ defmodule Web.Live.Policies.NewTest do
gateway_group = Fixtures.Gateways.create_group(account: account)
attrs =
Fixtures.Policies.policy_attrs()
|> Map.take([:name])
|> Map.put(:actor_group_id, group.id)
attrs = %{actor_group_id: group.id}
{:ok, lv, _html} =
conn

View File

@@ -14,6 +14,36 @@ defmodule Web.Live.Policies.ShowTest do
account: account,
subject: subject,
resource: resource,
conditions: [
%{
property: :current_utc_datetime,
operator: :is_in_day_of_week_time_ranges,
values: [
"M/true/UTC",
"T//UTC",
"W/true/UTC",
"R//UTC",
"F//UTC",
"S/10:00:00-15:00:00/UTC",
"U/23:00:00-23:59:59/UTC"
]
},
%{
property: :provider_id,
operator: :is_in,
values: [identity.provider_id]
},
%{
property: :remote_ip,
operator: :is_not_in_cidr,
values: ["0.0.0.0/0"]
},
%{
property: :remote_ip_location_region,
operator: :is_in,
values: ["US"]
}
],
description: "Test Policy"
)
@@ -125,6 +155,19 @@ defmodule Web.Live.Policies.ShowTest do
assert table["resource"] =~ policy.resource.name
assert table["description"] =~ policy.description
assert table["created"] =~ actor.name
assert table["conditions"] =~ "This policy can be used on"
assert table["conditions"] =~ "Mondays"
assert table["conditions"] =~ "Wednesdays"
assert table["conditions"] =~ "Saturdays (10:00:00 - 15:00:00 UTC)"
assert table["conditions"] =~ "Sundays (23:00:00 - 23:59:59 UTC)"
assert table["conditions"] =~ "from United States of America"
assert table["conditions"] =~ "from IP addresses that are"
assert table["conditions"] =~ "not in"
assert table["conditions"] =~ "0.0.0.0"
assert table["conditions"] =~ "when signed in"
assert table["conditions"] =~ "with"
assert table["conditions"] =~ "provider(s)"
end
test "renders logs table", %{

View File

@@ -88,6 +88,7 @@ config :domain, :enabled_features,
sign_up: true,
flow_activities: true,
self_hosted_relays: true,
policy_conditions: true,
multi_site_resources: true,
rest_api: true

View File

@@ -62,6 +62,7 @@ if config_env() == :prod do
sign_up: compile_config!(:feature_sign_up_enabled),
flow_activities: compile_config!(:feature_flow_activities_enabled),
self_hosted_relays: compile_config!(:feature_self_hosted_relays_enabled),
policy_conditions: compile_config!(:feature_policy_conditions_enabled),
multi_site_resources: compile_config!(:feature_multi_site_resources_enabled),
rest_api: compile_config!(:feature_rest_api_enabled)

View File

@@ -103,6 +103,7 @@
"tesla": {:hex, :tesla, "1.9.0", "8c22db6a826e56a087eeb8cdef56889731287f53feeb3f361dec5d4c8efb6f14", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "7c240c67e855f7e63e795bf16d6b3f5115a81d1f44b7fe4eadbf656bae0fef8a"},
"thousand_island": {:hex, :thousand_island, "1.3.5", "6022b6338f1635b3d32406ff98d68b843ba73b3aa95cfc27154223244f3a6ca5", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2be6954916fdfe4756af3239fb6b6d75d0b8063b5df03ba76fd8a4c87849e180"},
"tls_certificate_check": {:hex, :tls_certificate_check, "1.22.1", "0f450cc1568a67a65ce5e15df53c53f9a098c3da081c5f126199a72505858dc1", [:rebar3], [{:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "3092be0babdc0e14c2e900542351e066c0fa5a9cf4b3597559ad1e67f07938c0"},
"tzdata": {:hex, :tzdata, "1.1.1", "20c8043476dfda8504952d00adac41c6eda23912278add38edc140ae0c5bcc46", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "a69cec8352eafcd2e198dea28a34113b60fdc6cb57eb5ad65c10292a6ba89787"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"},
"wallaby": {:hex, :wallaby, "0.30.6", "7dc4c1213f3b52c4152581d126632bc7e06892336d3a0f582853efeeabd45a71", [:mix], [{:ecto_sql, ">= 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}, {:httpoison, "~> 0.12 or ~> 1.0 or ~> 2.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_ecto, ">= 3.0.0", [hex: :phoenix_ecto, repo: "hexpm", optional: true]}, {:web_driver_client, "~> 0.2.0", [hex: :web_driver_client, repo: "hexpm", optional: false]}], "hexpm", "50950c1d968549b54c20e16175c68c7fc0824138e2bb93feb11ef6add8eb23d4"},
"web_driver_client": {:hex, :web_driver_client, "0.2.0", "63b76cd9eb3b0716ec5467a0f8bead73d3d9612e63f7560d21357f03ad86e31a", [:mix], [{:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:tesla, "~> 1.3", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "83cc6092bc3e74926d1c8455f0ce927d5d1d36707b74d9a65e38c084aab0350f"},

View File

@@ -358,6 +358,10 @@ locals {
name = "FEATURE_SELF_HOSTED_RELAYS_ENABLED"
value = true
},
{
name = "FEATURE_POLICY_CONDITIONS_ENABLED"
value = true
},
{
name = "FEATURE_MULTI_SITE_RESOURCES_ENABLED"
value = true

View File

@@ -327,6 +327,10 @@ locals {
name = "FEATURE_SELF_HOSTED_RELAYS_ENABLED"
value = true
},
{
name = "FEATURE_POLICY_CONDITIONS_ENABLED"
value = true
},
{
name = "FEATURE_MULTI_SITE_RESOURCES_ENABLED"
value = true