From 650d7d79984c8bedfb7bae439c0b9fa76346cae7 Mon Sep 17 00:00:00 2001 From: Andrew Dryga Date: Sun, 9 Jun 2024 12:46:35 -0600 Subject: [PATCH] feat(portal): Add Policy conditions (#5144) Now policies can have additional conditions based on Client location (country or IP range), IdP provider used for sign in or the current time of the day at a given timezone. This covers use cases where employees can access the production system only from certain countries (states can be added later) or when contractors can only access internal tools during working hours. Closes https://github.com/firezone/firezone/issues/4743 Closes #4742 Closes #4741 Closes #4740 Screenshot 2024-05-31 at 13 50 53 Screenshot 2024-05-31 at 13 50 56 Screenshot 2024-05-31 at 13 51 01 Screenshot 2024-05-31 at 13 51 03 Screenshot 2024-05-31 at 14 56 06 --- docker-compose.yml | 4 + elixir/apps/api/lib/api/client/channel.ex | 12 + .../apps/api/test/api/client/channel_test.exs | 87 +++ .../domain/lib/domain/accounts/features.ex | 1 + .../lib/domain/accounts/features/changeset.ex | 10 +- .../domain/lib/domain/config/definitions.ex | 5 + elixir/apps/domain/lib/domain/flows.ex | 47 +- elixir/apps/domain/lib/domain/geo.ex | 540 ++++++++-------- elixir/apps/domain/lib/domain/policies.ex | 14 +- .../domain/lib/domain/policies/condition.ex | 18 + .../domain/policies/condition/changeset.ex | 66 ++ .../domain/policies/condition/evaluator.ex | 237 +++++++ .../apps/domain/lib/domain/policies/policy.ex | 2 + .../lib/domain/policies/policy/changeset.ex | 2 + .../apps/domain/lib/domain/repo/changeset.ex | 65 ++ .../domain/lib/domain/resources/resource.ex | 5 +- .../lib/domain/resources/resource/query.ex | 22 +- elixir/apps/domain/lib/domain/types/cidr.ex | 4 +- elixir/apps/domain/lib/domain/types/ip.ex | 3 + .../apps/domain/lib/domain/types/ip_port.ex | 6 +- elixir/apps/domain/mix.exs | 3 + ...0240507225142_add_policies_constraints.exs | 9 + elixir/apps/domain/priv/repo/seeds.exs | 1 + .../test/domain/config/validator_test.exs | 6 +- elixir/apps/domain/test/domain/flows_test.exs | 161 ++++- .../policies/condition/evaluator_test.exs | 480 ++++++++++++++ .../apps/domain/test/domain/policies_test.exs | 108 +++- .../domain/test/domain/resources_test.exs | 18 +- .../domain/test/support/fixtures/accounts.ex | 1 + .../web/lib/web/components/core_components.ex | 6 + .../web/lib/web/components/form_components.ex | 20 +- elixir/apps/web/lib/web/live/actors/show.ex | 4 +- elixir/apps/web/lib/web/live/clients/show.ex | 4 +- .../web/lib/web/live/policies/components.ex | 610 ++++++++++++++++++ elixir/apps/web/lib/web/live/policies/new.ex | 106 +-- elixir/apps/web/lib/web/live/policies/show.ex | 21 +- .../apps/web/lib/web/live/resources/show.ex | 6 +- elixir/apps/web/mix.exs | 2 + .../web/test/web/live/policies/new_test.exs | 170 ++++- .../web/test/web/live/policies/show_test.exs | 43 ++ elixir/config/config.exs | 1 + elixir/config/runtime.exs | 1 + elixir/mix.lock | 1 + terraform/environments/production/portal.tf | 4 + terraform/environments/staging/portal.tf | 4 + 45 files changed, 2540 insertions(+), 400 deletions(-) create mode 100644 elixir/apps/domain/lib/domain/policies/condition.ex create mode 100644 elixir/apps/domain/lib/domain/policies/condition/changeset.ex create mode 100644 elixir/apps/domain/lib/domain/policies/condition/evaluator.ex create mode 100644 elixir/apps/domain/priv/repo/migrations/20240507225142_add_policies_constraints.exs create mode 100644 elixir/apps/domain/test/domain/policies/condition/evaluator_test.exs 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