diff --git a/docker-compose.yml b/docker-compose.yml index b9b0fc1b8..075e77e0f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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" diff --git a/elixir/apps/api/lib/api/client/channel.ex b/elixir/apps/api/lib/api/client/channel.ex index ec2f88fcd..e28f17947 100644 --- a/elixir/apps/api/lib/api/client/channel.ex +++ b/elixir/apps/api/lib/api/client/channel.ex @@ -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} diff --git a/elixir/apps/api/test/api/client/channel_test.exs b/elixir/apps/api/test/api/client/channel_test.exs index 47e13fc3b..d490e1dc2 100644 --- a/elixir/apps/api/test/api/client/channel_test.exs +++ b/elixir/apps/api/test/api/client/channel_test.exs @@ -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, diff --git a/elixir/apps/domain/lib/domain/accounts/features.ex b/elixir/apps/domain/lib/domain/accounts/features.ex index c7ad31848..ade212ad5 100644 --- a/elixir/apps/domain/lib/domain/accounts/features.ex +++ b/elixir/apps/domain/lib/domain/accounts/features.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/accounts/features/changeset.ex b/elixir/apps/domain/lib/domain/accounts/features/changeset.ex index d22b8e2be..dc03a8521 100644 --- a/elixir/apps/domain/lib/domain/accounts/features/changeset.ex +++ b/elixir/apps/domain/lib/domain/accounts/features/changeset.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/config/definitions.ex b/elixir/apps/domain/lib/domain/config/definitions.ex index e8a80ed35..7d75ce5b6 100644 --- a/elixir/apps/domain/lib/domain/config/definitions.ex +++ b/elixir/apps/domain/lib/domain/config/definitions.ex @@ -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. """ diff --git a/elixir/apps/domain/lib/domain/flows.ex b/elixir/apps/domain/lib/domain/flows.ex index aafee5f12..4a22c99f5 100644 --- a/elixir/apps/domain/lib/domain/flows.ex +++ b/elixir/apps/domain/lib/domain/flows.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/geo.ex b/elixir/apps/domain/lib/domain/geo.ex index 4f483f263..33c2b26f8 100644 --- a/elixir/apps/domain/lib/domain/geo.ex +++ b/elixir/apps/domain/lib/domain/geo.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/policies.ex b/elixir/apps/domain/lib/domain/policies.ex index bcffd3d9f..6325c841c 100644 --- a/elixir/apps/domain/lib/domain/policies.ex +++ b/elixir/apps/domain/lib/domain/policies.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/policies/condition.ex b/elixir/apps/domain/lib/domain/policies/condition.ex new file mode 100644 index 000000000..64cbef5f3 --- /dev/null +++ b/elixir/apps/domain/lib/domain/policies/condition.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/policies/condition/changeset.ex b/elixir/apps/domain/lib/domain/policies/condition/changeset.ex new file mode 100644 index 000000000..43457ce1c --- /dev/null +++ b/elixir/apps/domain/lib/domain/policies/condition/changeset.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/policies/condition/evaluator.ex b/elixir/apps/domain/lib/domain/policies/condition/evaluator.ex new file mode 100644 index 000000000..29d2af61d --- /dev/null +++ b/elixir/apps/domain/lib/domain/policies/condition/evaluator.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/policies/policy.ex b/elixir/apps/domain/lib/domain/policies/policy.ex index 8bedfa1b9..e9184bf5b 100644 --- a/elixir/apps/domain/lib/domain/policies/policy.ex +++ b/elixir/apps/domain/lib/domain/policies/policy.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/policies/policy/changeset.ex b/elixir/apps/domain/lib/domain/policies/policy/changeset.ex index dfa91a6ad..5110a5777 100644 --- a/elixir/apps/domain/lib/domain/policies/policy/changeset.ex +++ b/elixir/apps/domain/lib/domain/policies/policy/changeset.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/repo/changeset.ex b/elixir/apps/domain/lib/domain/repo/changeset.ex index 6932bd129..0a3588f68 100644 --- a/elixir/apps/domain/lib/domain/repo/changeset.ex +++ b/elixir/apps/domain/lib/domain/repo/changeset.ex @@ -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") diff --git a/elixir/apps/domain/lib/domain/resources/resource.ex b/elixir/apps/domain/lib/domain/resources/resource.ex index 41d9b49c5..ed8f1af78 100644 --- a/elixir/apps/domain/lib/domain/resources/resource.ex +++ b/elixir/apps/domain/lib/domain/resources/resource.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/resources/resource/query.ex b/elixir/apps/domain/lib/domain/resources/resource/query.ex index 67ed11373..39ff3c990 100644 --- a/elixir/apps/domain/lib/domain/resources/resource/query.ex +++ b/elixir/apps/domain/lib/domain/resources/resource/query.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/types/cidr.ex b/elixir/apps/domain/lib/domain/types/cidr.ex index 229fd7008..6d0d0e559 100644 --- a/elixir/apps/domain/lib/domain/types/cidr.ex +++ b/elixir/apps/domain/lib/domain/types/cidr.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/types/ip.ex b/elixir/apps/domain/lib/domain/types/ip.ex index 2d0c088cd..8a4dcb07a 100644 --- a/elixir/apps/domain/lib/domain/types/ip.ex +++ b/elixir/apps/domain/lib/domain/types/ip.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/types/ip_port.ex b/elixir/apps/domain/lib/domain/types/ip_port.ex index 496a089a1..e756284c9 100644 --- a/elixir/apps/domain/lib/domain/types/ip_port.ex +++ b/elixir/apps/domain/lib/domain/types/ip_port.ex @@ -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 diff --git a/elixir/apps/domain/mix.exs b/elixir/apps/domain/mix.exs index db27f2de4..7fb0775d1 100644 --- a/elixir/apps/domain/mix.exs +++ b/elixir/apps/domain/mix.exs @@ -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}, diff --git a/elixir/apps/domain/priv/repo/migrations/20240507225142_add_policies_constraints.exs b/elixir/apps/domain/priv/repo/migrations/20240507225142_add_policies_constraints.exs new file mode 100644 index 000000000..445cf99fb --- /dev/null +++ b/elixir/apps/domain/priv/repo/migrations/20240507225142_add_policies_constraints.exs @@ -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 diff --git a/elixir/apps/domain/priv/repo/seeds.exs b/elixir/apps/domain/priv/repo/seeds.exs index c0d2a2711..9504d76b9 100644 --- a/elixir/apps/domain/priv/repo/seeds.exs +++ b/elixir/apps/domain/priv/repo/seeds.exs @@ -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, diff --git a/elixir/apps/domain/test/domain/config/validator_test.exs b/elixir/apps/domain/test/domain/config/validator_test.exs index 2a3206406..970b1a6a1 100644 --- a/elixir/apps/domain/test/domain/config/validator_test.exs +++ b/elixir/apps/domain/test/domain/config/validator_test.exs @@ -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]}} diff --git a/elixir/apps/domain/test/domain/flows_test.exs b/elixir/apps/domain/test/domain/flows_test.exs index 2884a182b..d9dcbde78 100644 --- a/elixir/apps/domain/test/domain/flows_test.exs +++ b/elixir/apps/domain/test/domain/flows_test.exs @@ -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", %{ diff --git a/elixir/apps/domain/test/domain/policies/condition/evaluator_test.exs b/elixir/apps/domain/test/domain/policies/condition/evaluator_test.exs new file mode 100644 index 000000000..0be4291ed --- /dev/null +++ b/elixir/apps/domain/test/domain/policies/condition/evaluator_test.exs @@ -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 diff --git a/elixir/apps/domain/test/domain/policies_test.exs b/elixir/apps/domain/test/domain/policies_test.exs index 933ee76ec..fbd17fc2a 100644 --- a/elixir/apps/domain/test/domain/policies_test.exs +++ b/elixir/apps/domain/test/domain/policies_test.exs @@ -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 diff --git a/elixir/apps/domain/test/domain/resources_test.exs b/elixir/apps/domain/test/domain/resources_test.exs index b1edd8264..e5323a859 100644 --- a/elixir/apps/domain/test/domain/resources_test.exs +++ b/elixir/apps/domain/test/domain/resources_test.exs @@ -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, diff --git a/elixir/apps/domain/test/support/fixtures/accounts.ex b/elixir/apps/domain/test/support/fixtures/accounts.ex index 4308f46ba..f63430ff7 100644 --- a/elixir/apps/domain/test/support/fixtures/accounts.ex +++ b/elixir/apps/domain/test/support/fixtures/accounts.ex @@ -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, diff --git a/elixir/apps/web/lib/web/components/core_components.ex b/elixir/apps/web/lib/web/components/core_components.ex index b833f7b5b..e1ec4a846 100644 --- a/elixir/apps/web/lib/web/components/core_components.ex +++ b/elixir/apps/web/lib/web/components/core_components.ex @@ -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 diff --git a/elixir/apps/web/lib/web/components/form_components.ex b/elixir/apps/web/lib/web/components/form_components.ex index da0cdb0e9..5fe9e7ea0 100644 --- a/elixir/apps/web/lib/web/components/form_components.ex +++ b/elixir/apps/web/lib/web/components/form_components.ex @@ -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
<.label :if={@label} for={@id}><%= @label %> <.label for={@id}><%= @label %> "w-3 h-3", "sm" => "w-3 h-3", diff --git a/elixir/apps/web/lib/web/live/actors/show.ex b/elixir/apps/web/lib/web/live/actors/show.ex index 43adae6fb..69f73dbbf 100644 --- a/elixir/apps/web/lib/web/live/actors/show.ex +++ b/elixir/apps/web/lib/web/live/actors/show.ex @@ -405,9 +405,9 @@ defmodule Web.Actors.Show do <.section> - <:title>Activity + <:title>Authorized Activity <:help> - Attempts to access resources by this actor. + Authorized attempts by actors to access the resource governed by this policy. <:content> <.live_table diff --git a/elixir/apps/web/lib/web/live/clients/show.ex b/elixir/apps/web/lib/web/live/clients/show.ex index 5cdf9d179..c4b28c884 100644 --- a/elixir/apps/web/lib/web/live/clients/show.ex +++ b/elixir/apps/web/lib/web/live/clients/show.ex @@ -156,9 +156,9 @@ defmodule Web.Clients.Show do <.section> - <:title>Activity + <:title>Authorized Activity <:help> - Attempts by the actor using this client to access resources. + Authorized attempts by actors to access the resource governed by this policy. <:content> <.live_table diff --git a/elixir/apps/web/lib/web/live/policies/components.ex b/elixir/apps/web/lib/web/live/policies/components.ex index 9568426b9..785499f51 100644 --- a/elixir/apps/web/lib/web/live/policies/components.ex +++ b/elixir/apps/web/lib/web/live/policies/components.ex @@ -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""" + + There are no conditions defined for this policy. + + + This policy can be used + <.condition + :for={condition <- @conditions} + providers={@providers} + property={condition.property} + operator={condition.operator} + values={condition.values} + /> + + """ + end + + defp condition(%{property: :remote_ip_location_region} = assigns) do + ~H""" + + from + from any counties except + + <%= @values |> Enum.map(&Domain.Geo.country_common_name!/1) |> Enum.join(", ") %> + + + """ + end + + defp condition(%{property: :remote_ip} = assigns) do + ~H""" + + from IP addresses that are in + not in + <%= Enum.join(@values, ", ") %> + + """ + 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""" + + when signed in + with + not with + <.intersperse_blocks> + <:separator>, + + <:item :for={provider <- @providers}> + <.link navigate={"/providers/#{provider.id}"} class={[link_style(), "font-medium"]}> + <%= provider.name %> + + + + provider(s) + + """ + 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""" + + on + <.intersperse_blocks> + <:separator>, + + <:item :for={{day_of_week, tz_time_ranges} <- @tz_time_ranges_by_dow}> + + <%= day_of_week_name(day_of_week) <> "s" %> + + <%= "(" <> + Enum.map_join(time_ranges, ", ", fn {from, to} -> + "#{from} - #{to}" + end) <> " #{timezone})" %> + + + + + + """ + 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""" +
+
+ Conditions + <%= 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 + + + <% end %> +
+ + <.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} + /> +
+ """ + end + + defp remote_ip_location_region_condition_form(assigns) do + ~H""" +
+ <% 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" + /> + +
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" + ) + } + > + + <.icon id="policy_conditions_remote_ip_location_region_chevron" name="hero-chevron-down" /> + Client location + + +

+ Restrict access based on the location of the Client. +

+
+ +
+ <.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 %> +
0} class="text-right mt-3 text-sm text-neutral-900"> + or +
+ +
+ <.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} + /> +
+ <% end %> +
+
+ """ + end + + defp remote_ip_condition_form(assigns) do + ~H""" +
+ <% 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" + /> + +
JS.toggle_class("hero-chevron-down", + to: "#policy_conditions_remote_ip_chevron" + ) + |> JS.toggle_class("hero-chevron-up", + to: "#policy_conditions_remote_ip_chevron" + ) + } + > + + <.icon id="policy_conditions_remote_ip_chevron" name="hero-chevron-down" class="w-5 h-5" /> + IP address + + +

+ Restrict access based on the Client's IP address or CIDR range. +

+
+ +
+ <.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 %> +
0} class="text-right mt-3 text-sm text-neutral-900"> + or +
+ +
+ <.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} + /> +
+ <% end %> +
+
+ """ + end + + defp provider_id_condition_form(assigns) do + ~H""" +
+ <% 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" + /> + +
JS.toggle_class("hero-chevron-down", + to: "#policy_conditions_provider_id_chevron" + ) + |> JS.toggle_class("hero-chevron-up", + to: "#policy_conditions_provider_id_chevron" + ) + } + > + + <.icon id="policy_conditions_provider_id_chevron" name="hero-chevron-down" class="w-5 h-5" /> + Authentication Provider + + +

+ Restrict access based on the identity provider that was used to sign in. +

+
+ +
+ <.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 %> +
0} class="text-right mt-3 text-sm text-neutral-900"> + or +
+ +
+ <.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} + /> +
+ <% end %> +
+
+ """ + end + + defp current_utc_datetime_condition_form(assigns) do + assigns = assign_new(assigns, :days_of_week, fn -> @days_of_week end) + + ~H""" +
+ <% condition_form = find_condition_form(@form[:conditions], :current_utc_datetime) %> + + <.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} + /> + +
JS.toggle_class("hero-chevron-down", + to: "#policy_conditions_current_utc_datetime_chevron" + ) + |> JS.toggle_class("hero-chevron-up", + to: "#policy_conditions_current_utc_datetime_chevron" + ) + } + > + + <.icon + id="policy_conditions_current_utc_datetime_chevron" + name="hero-chevron-down" + class="w-5 h-5" + /> Current time + + +

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

+
+ +
+ <.input + type="select" + label="Timezone" + name="policy[conditions][current_utc_datetime][timezone]" + id="policy_conditions_current_utc_datetime_timezone" + field={condition_form[:timezone]} + options={Tzdata.zone_list()} + disabled={@disabled} + value={condition_form[:timezone].value || @timezone} + /> + +
+ <.current_utc_datetime_condition_day_input + :for={{code, _name} <- @days_of_week} + disabled={@disabled} + condition_form={condition_form} + day={code} + /> +
+
+
+ """ + end + + 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 diff --git a/elixir/apps/web/lib/web/live/policies/new.ex b/elixir/apps/web/lib/web/live/policies/new.ex index 758ae88ba..8b1657074 100644 --- a/elixir/apps/web/lib/web/live/policies/new.ex +++ b/elixir/apps/web/lib/web/live/policies/new.ex @@ -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 <:content>
-

Policy details

+

Define a Policy

<.base_error form={@form} field={:base} />
- <.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[: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" + /> +
+ + <.condition_form + form={@form} + account={@account} + timezone={@timezone} + providers={@providers} />
@@ -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 diff --git a/elixir/apps/web/lib/web/live/policies/show.ex b/elixir/apps/web/lib/web/live/policies/show.ex index 84b6ff671..0fe4f13fe 100644 --- a/elixir/apps/web/lib/web/live/policies/show.ex +++ b/elixir/apps/web/lib/web/live/policies/show.ex @@ -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 - <.vertical_table_row> + <.vertical_table_row :if={@policy.conditions != []}> + <:label> + Conditions + + <:value> + <.conditions providers={@providers} conditions={@policy.conditions} /> + + + <.vertical_table_row :if={@policy.description}> <:label> Description @@ -153,11 +164,9 @@ defmodule Web.Policies.Show do <.section> - <:title> - Activity - + <:title>Authorized Activity <:help> - Attempts by actors to access the resource governed by this policy. + Authorized attempts by actors to access the resource governed by this policy. <:content> <.live_table diff --git a/elixir/apps/web/lib/web/live/resources/show.ex b/elixir/apps/web/lib/web/live/resources/show.ex index 0a1f7d67c..af69eb881 100644 --- a/elixir/apps/web/lib/web/live/resources/show.ex +++ b/elixir/apps/web/lib/web/live/resources/show.ex @@ -251,11 +251,9 @@ defmodule Web.Resources.Show do <.section> - <:title> - Activity - + <:title>Authorized Activity <:help> - Attempts by actors to access this resource. + Authorized attempts by actors to access the resource governed by this policy. <:content> <.live_table diff --git a/elixir/apps/web/mix.exs b/elixir/apps/web/mix.exs index 2eab94eea..af58f22d2 100644 --- a/elixir/apps/web/mix.exs +++ b/elixir/apps/web/mix.exs @@ -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 diff --git a/elixir/apps/web/test/web/live/policies/new_test.exs b/elixir/apps/web/test/web/live/policies/new_test.exs index 9c8752e21..76923dc3c 100644 --- a/elixir/apps/web/test/web/live/policies/new_test.exs +++ b/elixir/apps/web/test/web/live/policies/new_test.exs @@ -60,6 +60,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 diff --git a/elixir/apps/web/test/web/live/policies/show_test.exs b/elixir/apps/web/test/web/live/policies/show_test.exs index add961738..7f2040803 100644 --- a/elixir/apps/web/test/web/live/policies/show_test.exs +++ b/elixir/apps/web/test/web/live/policies/show_test.exs @@ -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", %{ diff --git a/elixir/config/config.exs b/elixir/config/config.exs index 9bd933500..282df6044 100644 --- a/elixir/config/config.exs +++ b/elixir/config/config.exs @@ -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 diff --git a/elixir/config/runtime.exs b/elixir/config/runtime.exs index 4074526c9..6d7cba1a6 100644 --- a/elixir/config/runtime.exs +++ b/elixir/config/runtime.exs @@ -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) diff --git a/elixir/mix.lock b/elixir/mix.lock index f1716b2d6..1f29a445c 100644 --- a/elixir/mix.lock +++ b/elixir/mix.lock @@ -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"}, diff --git a/terraform/environments/production/portal.tf b/terraform/environments/production/portal.tf index 15a40bdef..7afc61658 100644 --- a/terraform/environments/production/portal.tf +++ b/terraform/environments/production/portal.tf @@ -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 diff --git a/terraform/environments/staging/portal.tf b/terraform/environments/staging/portal.tf index cd8a2cbb8..167a095bc 100644 --- a/terraform/environments/staging/portal.tf +++ b/terraform/environments/staging/portal.tf @@ -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