From 5b1e3ea1d1c5616744f3078408aed9d5afa5afb0 Mon Sep 17 00:00:00 2001 From: Andrew Dryga Date: Tue, 20 Feb 2024 15:01:17 -0600 Subject: [PATCH] feat(portal): Billing system (#3642) --- .pre-commit-config.yaml | 4 +- elixir/.credo.exs | 8 +- elixir/README.md | 54 ++- elixir/apps/api/lib/api.ex | 3 +- elixir/apps/api/lib/api/client/channel.ex | 18 +- .../api/lib/api/client/views/interface.ex | 11 +- .../lib/api/controllers/example_controller.ex | 9 + .../lib/api/controllers/health_controller.ex | 10 + .../integrations/stripe/webhook_controller.ex | 94 +++++ elixir/apps/api/lib/api/endpoint.ex | 32 +- elixir/apps/api/lib/api/plugs/auth.ex | 74 ++++ elixir/apps/api/lib/api/router.ex | 35 ++ elixir/apps/api/lib/api/sockets.ex | 3 + elixir/apps/api/mix.exs | 1 + .../apps/api/test/api/client/channel_test.exs | 18 +- .../controllers/example_controller_test.exs | 22 + .../stripe/webhook_controller_test.exs | 87 ++++ elixir/apps/api/test/support/conn_case.ex | 41 ++ elixir/apps/domain/lib/domain/accounts.ex | 100 ++++- .../domain/lib/domain/accounts/account.ex | 22 + .../lib/domain/accounts/account/changeset.ex | 80 +++- .../lib/domain/accounts/account/query.ex | 30 +- .../domain/lib/domain/accounts/authorizer.ex | 12 +- .../apps/domain/lib/domain/accounts/config.ex | 13 + .../lib/domain/accounts/config/changeset.ex | 79 ++++ .../domain/lib/domain/accounts/features.ex | 12 + .../lib/domain/accounts/features/changeset.ex | 11 + .../apps/domain/lib/domain/accounts/limits.ex | 11 + .../lib/domain/accounts/limits/changeset.ex | 15 + elixir/apps/domain/lib/domain/actors.ex | 57 ++- .../domain/lib/domain/actors/actor/query.ex | 11 + elixir/apps/domain/lib/domain/application.ex | 3 +- elixir/apps/domain/lib/domain/auth.ex | 12 +- .../apps/domain/lib/domain/auth/adapters.ex | 8 +- .../auth/adapters/google_workspace/jobs.ex | 13 +- .../auth/adapters/microsoft_entra/jobs.ex | 13 +- .../lib/domain/auth/adapters/okta/jobs.ex | 13 +- .../lib/domain/auth/provider/changeset.ex | 15 +- elixir/apps/domain/lib/domain/auth/roles.ex | 2 +- elixir/apps/domain/lib/domain/billing.ex | 366 +++++++++++++++++ .../domain/{config => billing}/authorizer.ex | 8 +- elixir/apps/domain/lib/domain/billing/jobs.ex | 84 ++++ .../lib/domain/billing/stripe/api_client.ex | 104 +++++ elixir/apps/domain/lib/domain/clients.ex | 9 + .../domain/lib/domain/clients/client/query.ex | 41 +- elixir/apps/domain/lib/domain/config.ex | 119 +----- .../domain/lib/domain/config/configuration.ex | 14 - .../domain/config/configuration/changeset.ex | 61 --- .../configuration/clients_upstream_dns.ex | 63 --- .../lib/domain/config/configuration/query.ex | 15 - .../domain/lib/domain/config/definitions.ex | 60 +-- .../apps/domain/lib/domain/config/errors.ex | 2 +- elixir/apps/domain/lib/domain/config/logo.ex | 48 --- elixir/apps/domain/lib/domain/gateways.ex | 13 +- elixir/apps/domain/lib/domain/ops.ex | 35 +- .../lib/domain/tokens/token/changeset.ex | 2 +- .../20240212204654_add_accounts_billing.exs | 19 + .../20240216144024_add_various_indexes.exs | 22 + elixir/apps/domain/priv/repo/seeds.exs | 18 +- .../apps/domain/test/domain/accounts_test.exs | 267 ++++++++++++- .../apps/domain/test/domain/actors_test.exs | 76 +++- .../adapters/google_workspace/jobs_test.exs | 13 + .../adapters/microsoft_entra/jobs_test.exs | 13 + elixir/apps/domain/test/domain/auth_test.exs | 42 +- .../domain/test/domain/billing/jobs_test.exs | 71 ++++ .../apps/domain/test/domain/billing_test.exs | 231 +++++++++++ .../apps/domain/test/domain/clients_test.exs | 40 ++ .../test/domain/config/definition_test.exs | 8 +- .../test/domain/config/resolver_test.exs | 2 +- .../apps/domain/test/domain/config_test.exs | 272 +------------ .../apps/domain/test/domain/gateways_test.exs | 22 +- elixir/apps/domain/test/domain/ops_test.exs | 73 +++- .../domain/test/support/fixtures/accounts.ex | 29 +- .../apps/domain/test/support/fixtures/auth.ex | 11 +- .../domain/test/support/fixtures/config.ex | 33 -- .../apps/domain/test/support/mocks/stripe.ex | 378 ++++++++++++++++++ .../web/lib/web/components/core_components.ex | 54 ++- .../web/lib/web/components/form_components.ex | 2 +- .../lib/web/components/layouts/app.html.heex | 33 +- .../apps/web/lib/web/controllers/home_html.ex | 5 +- elixir/apps/web/lib/web/endpoint.ex | 17 + .../web/live/actors/service_accounts/new.ex | 16 + elixir/apps/web/lib/web/live/actors/show.ex | 4 +- .../apps/web/lib/web/live/actors/users/new.ex | 16 + elixir/apps/web/lib/web/live/clients/show.ex | 4 +- elixir/apps/web/lib/web/live/policies/show.ex | 4 +- .../web/lib/web/live/relay_groups/edit.ex | 4 +- .../web/lib/web/live/relay_groups/index.ex | 4 +- .../apps/web/lib/web/live/relay_groups/new.ex | 4 +- .../lib/web/live/relay_groups/new_token.ex | 4 +- .../web/lib/web/live/relay_groups/show.ex | 4 +- elixir/apps/web/lib/web/live/relays/show.ex | 4 +- .../web/lib/web/live/resources/components.ex | 4 +- .../apps/web/lib/web/live/resources/edit.ex | 8 +- .../apps/web/lib/web/live/resources/index.ex | 2 +- elixir/apps/web/lib/web/live/resources/new.ex | 8 +- .../apps/web/lib/web/live/resources/show.ex | 6 +- .../apps/web/lib/web/live/settings/account.ex | 50 ++- .../apps/web/lib/web/live/settings/billing.ex | 215 ++++++++++ elixir/apps/web/lib/web/live/settings/dns.ex | 205 +++++----- .../live/settings/identity_providers/new.ex | 55 +-- .../identity_providers/system/show.ex | 11 - elixir/apps/web/lib/web/live/sign_in.ex | 14 +- elixir/apps/web/lib/web/live/sign_up.ex | 9 +- elixir/apps/web/lib/web/live/sites/new.ex | 16 + elixir/apps/web/lib/web/router.ex | 9 +- ...le-developer-merchantid-domain-association | 1 + .../live/actors/service_accounts/new_test.exs | 32 ++ .../test/web/live/actors/users/new_test.exs | 33 ++ .../web/live/settings/account/index_test.exs | 63 --- .../test/web/live/settings/account_test.exs | 124 ++++++ .../test/web/live/settings/billing_test.exs | 153 +++++++ .../{dns/index_test.exs => dns_test.exs} | 118 +++--- .../settings/identity_providers/new_test.exs | 6 +- .../identity_providers/system/show_test.exs | 10 +- .../apps/web/test/web/live/sidebar_test.exs | 24 +- .../apps/web/test/web/live/sign_in_test.exs | 9 + .../apps/web/test/web/live/sign_up_test.exs | 8 + .../apps/web/test/web/live/sites/new_test.exs | 32 ++ elixir/config/config.exs | 15 +- elixir/config/dev.exs | 8 +- elixir/config/runtime.exs | 13 +- elixir/config/test.exs | 3 +- kotlin/android/README.md | 8 - terraform/environments/production/main.tf | 17 + .../environments/production/variables.tf | 32 +- terraform/environments/staging/main.tf | 17 + terraform/environments/staging/variables.tf | 14 + 128 files changed, 4111 insertions(+), 1160 deletions(-) create mode 100644 elixir/apps/api/lib/api/controllers/example_controller.ex create mode 100644 elixir/apps/api/lib/api/controllers/health_controller.ex create mode 100644 elixir/apps/api/lib/api/controllers/integrations/stripe/webhook_controller.ex create mode 100644 elixir/apps/api/lib/api/plugs/auth.ex create mode 100644 elixir/apps/api/lib/api/router.ex create mode 100644 elixir/apps/api/test/api/controllers/example_controller_test.exs create mode 100644 elixir/apps/api/test/api/controllers/integrations/stripe/webhook_controller_test.exs create mode 100644 elixir/apps/domain/lib/domain/accounts/config.ex create mode 100644 elixir/apps/domain/lib/domain/accounts/config/changeset.ex create mode 100644 elixir/apps/domain/lib/domain/accounts/features.ex create mode 100644 elixir/apps/domain/lib/domain/accounts/features/changeset.ex create mode 100644 elixir/apps/domain/lib/domain/accounts/limits.ex create mode 100644 elixir/apps/domain/lib/domain/accounts/limits/changeset.ex create mode 100644 elixir/apps/domain/lib/domain/billing.ex rename elixir/apps/domain/lib/domain/{config => billing}/authorizer.ex (50%) create mode 100644 elixir/apps/domain/lib/domain/billing/jobs.ex create mode 100644 elixir/apps/domain/lib/domain/billing/stripe/api_client.ex delete mode 100644 elixir/apps/domain/lib/domain/config/configuration.ex delete mode 100644 elixir/apps/domain/lib/domain/config/configuration/changeset.ex delete mode 100644 elixir/apps/domain/lib/domain/config/configuration/clients_upstream_dns.ex delete mode 100644 elixir/apps/domain/lib/domain/config/configuration/query.ex delete mode 100644 elixir/apps/domain/lib/domain/config/logo.ex create mode 100644 elixir/apps/domain/priv/repo/migrations/20240212204654_add_accounts_billing.exs create mode 100644 elixir/apps/domain/priv/repo/migrations/20240216144024_add_various_indexes.exs create mode 100644 elixir/apps/domain/test/domain/billing/jobs_test.exs create mode 100644 elixir/apps/domain/test/domain/billing_test.exs delete mode 100644 elixir/apps/domain/test/support/fixtures/config.ex create mode 100644 elixir/apps/domain/test/support/mocks/stripe.ex create mode 100644 elixir/apps/web/lib/web/live/settings/billing.ex create mode 100644 elixir/apps/web/priv/static/.well-known/apple-developer-merchantid-domain-association delete mode 100644 elixir/apps/web/test/web/live/settings/account/index_test.exs create mode 100644 elixir/apps/web/test/web/live/settings/account_test.exs create mode 100644 elixir/apps/web/test/web/live/settings/billing_test.exs rename elixir/apps/web/test/web/live/settings/{dns/index_test.exs => dns_test.exs} (51%) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1eeb42d87..7f33d6840 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,11 +14,11 @@ repos: - id: mixed-line-ending args: ["--fix=lf"] description: Forces to replace line ending by the UNIX 'lf' character. - exclude: '(^website/public/images/|^kotlin/android/gradlew.bat|^rust/windows-client/wintun/)' + exclude: "(^website/public/images/|^kotlin/android/gradlew.bat|^rust/windows-client/wintun/|^elixir/apps/web/priv/static/)" - id: check-yaml - id: check-merge-conflict - id: end-of-file-fixer - exclude: ^website/public/images/ + exclude: (^website/public/images/|^elixir/apps/web/priv/static/) - id: trailing-whitespace exclude: ^website/public/images/ - id: check-merge-conflict diff --git a/elixir/.credo.exs b/elixir/.credo.exs index 7f6eff047..a2c28dd6d 100644 --- a/elixir/.credo.exs +++ b/elixir/.credo.exs @@ -28,10 +28,14 @@ "web/", "apps/*/lib/", "apps/*/src/", - "apps/*/test/", "apps/*/web/" ], - excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] + excluded: [ + ~r"/_build/", + ~r"/deps/", + ~r"/node_modules/", + "apps/*/test/" + ] }, # # Load and configure plugins here: diff --git a/elixir/README.md b/elixir/README.md index fd5681031..d03af325d 100644 --- a/elixir/README.md +++ b/elixir/README.md @@ -234,6 +234,50 @@ account_id = "c89bcc8c-9392-4dae-a40d-888aef6d28e0" } ``` +### Connecting billing in dev mode for manual testing + +Prerequisites: + +* A Stripe account (Note: for the Firezone team, you will need to be invited to the Firezone Stripe account) +* [Stripe CLI](https://github.com/stripe/stripe-cli) + +Steps: + +1. Use static seeds to provision account ID that corresponds to staging setup on + Stripe: + + ```bash + STATIC_SEEDS=true mix do ecto.reset, ecto.seed + ``` + +1. Start Stripe CLI webhook proxy: + + ```bash + stripe listen --forward-to localhost:13001/integrations/stripe/webhooks + ``` + +1. Start the Phoenix server with enabled billing from the [`elixir/`](./) folder + using a [test mode token](https://dashboard.stripe.com/test/apikeys): + + ```bash + cd elixir/ + BILLING_ENABLED=true STRIPE_SECRET_KEY="...copy from stripe dashboard..." STRIPE_WEBHOOK_SIGNING_SECRET="...copy from stripe cli tool.." mix phx.server + ``` + +When updating the billing plan in stripe, use the [Stripe Testing Docs](https://docs.stripe.com/testing#testing-interactively) for how to add test payment info + +### Acceptance tests + +You can disable headless mode for the browser by adding + +```elixir + + @tag debug: true + feature .... +``` + +to the acceptance test that you are running. + ## Connecting to a staging or production instances We use Google Cloud Platform for all our staging and production infrastructure. @@ -295,11 +339,11 @@ Useful for onboarding beta customers. See the `Domain.Ops.provision_account/1` function: ```elixir -iex> Domain.Ops.provision_account(%{ - account_name: "Customer Account", - account_slug: "customer_account", - account_admin_name: "Test User", - account_admin_email: "test@firezone.localhost" +iex> Domain.Ops.create_and_provision_account(%{ + name: "Customer Account", + slug: "customer_account", + admin_name: "Test User", + admin_email: "test@firezone.localhost" }) ``` diff --git a/elixir/apps/api/lib/api.ex b/elixir/apps/api/lib/api.ex index 97bd98553..57299ae51 100644 --- a/elixir/apps/api/lib/api.ex +++ b/elixir/apps/api/lib/api.ex @@ -18,8 +18,7 @@ defmodule API do def controller do quote do use Phoenix.Controller, - formats: [:html, :json], - layouts: [html: API.Layouts] + formats: [:json] import Plug.Conn diff --git a/elixir/apps/api/lib/api/client/channel.ex b/elixir/apps/api/lib/api/client/channel.ex index d35419aa1..7708bc756 100644 --- a/elixir/apps/api/lib/api/client/channel.ex +++ b/elixir/apps/api/lib/api/client/channel.ex @@ -2,7 +2,7 @@ defmodule API.Client.Channel do use API, :channel alias API.Client.Views alias Domain.Instrumentation - alias Domain.{Config, Clients, Actors, Resources, Gateways, Relays, Policies, Flows} + alias Domain.{Accounts, Clients, Actors, Resources, Gateways, Relays, Policies, Flows} require Logger require OpenTelemetry.Tracer @@ -51,8 +51,8 @@ defmodule API.Client.Channel do OpenTelemetry.Tracer.with_span "client.after_join" do :ok = Clients.connect_client(socket.assigns.client) - # Subscribe for config updates - :ok = Config.subscribe_to_events_in_account(socket.assigns.client.account_id) + # Subscribe for account config updates + :ok = Accounts.subscribe_to_events_in_account(socket.assigns.client.account_id) {:ok, resources} = Resources.list_authorized_resources(socket.assigns.subject, @@ -76,7 +76,11 @@ defmodule API.Client.Channel do :ok = push(socket, "init", %{ resources: Views.Resource.render_many(resources), - interface: Views.Interface.render(socket.assigns.client) + interface: + Views.Interface.render(%{ + socket.assigns.client + | account: socket.assigns.subject.account + }) }) {:noreply, socket} @@ -86,7 +90,11 @@ defmodule API.Client.Channel do def handle_info(:config_changed, socket) do :ok = push(socket, "config_changed", %{ - interface: Views.Interface.render(socket.assigns.client) + interface: + Views.Interface.render(%{ + socket.assigns.client + | account: socket.assigns.subject.account + }) }) {:noreply, socket} diff --git a/elixir/apps/api/lib/api/client/views/interface.ex b/elixir/apps/api/lib/api/client/views/interface.ex index 46d57a24a..368295b69 100644 --- a/elixir/apps/api/lib/api/client/views/interface.ex +++ b/elixir/apps/api/lib/api/client/views/interface.ex @@ -1,14 +1,13 @@ defmodule API.Client.Views.Interface do - alias Domain.Clients - alias Domain.Config.Configuration.ClientsUpstreamDNS + alias Domain.{Accounts, Clients} def render(%Clients.Client{} = client) do upstream_dns = - Clients.fetch_client_config!(client) - |> Map.fetch!(:clients_upstream_dns) + client.account.config + |> Map.get(:clients_upstream_dns, []) |> Enum.map(fn dns_config -> - addr = ClientsUpstreamDNS.normalize_dns_address(dns_config) - Map.from_struct(%{dns_config | address: addr}) + address = Accounts.Config.Changeset.normalize_dns_address(dns_config) + Map.from_struct(%{dns_config | address: address}) end) %{ diff --git a/elixir/apps/api/lib/api/controllers/example_controller.ex b/elixir/apps/api/lib/api/controllers/example_controller.ex new file mode 100644 index 000000000..b4a9ddf9b --- /dev/null +++ b/elixir/apps/api/lib/api/controllers/example_controller.ex @@ -0,0 +1,9 @@ +defmodule API.ExampleController do + use API, :controller + + def echo(conn, params) do + conn + |> put_resp_content_type("application/json") + |> send_resp(200, Jason.encode!(params)) + end +end diff --git a/elixir/apps/api/lib/api/controllers/health_controller.ex b/elixir/apps/api/lib/api/controllers/health_controller.ex new file mode 100644 index 000000000..62e164e33 --- /dev/null +++ b/elixir/apps/api/lib/api/controllers/health_controller.ex @@ -0,0 +1,10 @@ +defmodule API.HealthController do + use API, :controller + + def healthz(conn, _params) do + conn + |> put_resp_content_type("application/json") + |> send_resp(200, Jason.encode!(%{status: "ok"})) + |> halt() + end +end diff --git a/elixir/apps/api/lib/api/controllers/integrations/stripe/webhook_controller.ex b/elixir/apps/api/lib/api/controllers/integrations/stripe/webhook_controller.ex new file mode 100644 index 000000000..72e8875bc --- /dev/null +++ b/elixir/apps/api/lib/api/controllers/integrations/stripe/webhook_controller.ex @@ -0,0 +1,94 @@ +defmodule API.Integrations.Stripe.WebhookController do + use API, :controller + alias Domain.Billing + require Logger + + @tolerance 300 + @scheme "v1" + + def handle_webhook(conn, _params) do + with [signature_header] <- get_req_header(conn, "stripe-signature"), + {:ok, body, conn} <- read_body(conn), + {:ok, {timestamp, signatures}} <- fetch_timestamp_and_signatures(signature_header), + :ok <- verify_timestamp(timestamp, @tolerance), + secret = Billing.fetch_webhook_signing_secret!(), + :ok <- verify_signatures(signatures, timestamp, body, secret), + {:ok, payload} <- Jason.decode(body), + :ok <- Billing.handle_events([payload]) do + send_resp(conn, 200, "") + else + [] -> + send_resp(conn, 400, "Bad Request: missing signature header") + + {:error, :missing_timestamp} -> + send_resp(conn, 400, "Bad Request: missing timestamp") + + {:error, :missing_signatures} -> + send_resp(conn, 400, "Bad Request: missing signatures") + + {:error, :stale_event} -> + send_resp(conn, 400, "Bad Request: expired signature") + + {:error, :invalid_signature} -> + send_resp(conn, 400, "Bad Request: invalid signature") + + reason -> + :ok = Logger.error("Stripe webhook failed", reason: inspect(reason)) + send_resp(conn, 500, "Internal Error") + end + end + + defp fetch_timestamp_and_signatures(signature_header) do + signature_header + |> String.split(",") + |> Enum.map(&String.split(&1, "=")) + |> Enum.reduce({nil, []}, fn + ["t", timestamp], {nil, signatures} -> + {String.to_integer(timestamp), signatures} + + [@scheme, signature], {timestamp, signatures} -> + {timestamp, [signature | signatures]} + + _, acc -> + acc + end) + |> case do + {nil, _} -> + {:error, :missing_timestamp} + + {_, []} -> + {:error, :missing_signatures} + + {timestamp, signatures} -> + {:ok, {timestamp, signatures}} + end + end + + defp verify_timestamp(timestamp, tolerance) do + if timestamp < System.system_time(:second) - tolerance do + {:error, :stale_event} + else + :ok + end + end + + defp verify_signatures(signatures, timestamp, payload, secret) do + expected_signature = sign(timestamp, secret, payload) + + if Enum.any?(signatures, &Plug.Crypto.secure_compare(&1, expected_signature)) do + :ok + else + {:error, :invalid_signature} + end + end + + @doc false + def sign(timestamp, secret, payload) do + hmac_sha256(secret, "#{timestamp}.#{payload}") + end + + defp hmac_sha256(secret, payload) do + :crypto.mac(:hmac, :sha256, secret, payload) + |> Base.encode16(case: :lower) + end +end diff --git a/elixir/apps/api/lib/api/endpoint.ex b/elixir/apps/api/lib/api/endpoint.ex index 2b3e8b763..0104df2cc 100644 --- a/elixir/apps/api/lib/api/endpoint.ex +++ b/elixir/apps/api/lib/api/endpoint.ex @@ -1,6 +1,10 @@ defmodule API.Endpoint do use Phoenix.Endpoint, otp_app: :api + if Application.compile_env(:domain, :sql_sandbox) do + plug Phoenix.Ecto.SQL.Sandbox + end + plug Plug.RewriteOn, [:x_forwarded_host, :x_forwarded_port, :x_forwarded_proto] plug Plug.MethodOverride plug :put_hsts_header @@ -24,8 +28,15 @@ defmodule API.Endpoint do socket "/client", API.Client.Socket, API.Sockets.options() socket "/relay", API.Relay.Socket, API.Sockets.options() - plug :healthz - plug :not_found + plug :fetch_user_agent + plug API.Router + + def fetch_user_agent(%Plug.Conn{} = conn, _opts) do + case Plug.Conn.get_req_header(conn, "user-agent") do + [user_agent | _] -> Plug.Conn.assign(conn, :user_agent, user_agent) + _ -> conn + end + end def put_hsts_header(conn, _opts) do scheme = @@ -43,23 +54,6 @@ defmodule API.Endpoint do end end - def healthz(%Plug.Conn{request_path: "/healthz"} = conn, _opts) do - conn - |> put_resp_content_type("application/json") - |> send_resp(200, Jason.encode!(%{status: "ok"})) - |> halt() - end - - def healthz(conn, _opts) do - conn - end - - def not_found(conn, _opts) do - conn - |> send_resp(:not_found, "Not found") - |> halt() - end - def real_ip_opts do [ headers: ["x-forwarded-for"], diff --git a/elixir/apps/api/lib/api/plugs/auth.ex b/elixir/apps/api/lib/api/plugs/auth.ex new file mode 100644 index 000000000..9fdcb1029 --- /dev/null +++ b/elixir/apps/api/lib/api/plugs/auth.ex @@ -0,0 +1,74 @@ +defmodule API.Plugs.Auth do + import Plug.Conn + + def init(opts), do: Keyword.get(opts, :context_type, :api_client) + + def call(conn, context_type) do + context = get_auth_context(conn, context_type) + + with ["Bearer " <> encoded_token] <- get_req_header(conn, "authorization"), + {:ok, subject} <- Domain.Auth.authenticate(encoded_token, context) do + assign(conn, :subject, subject) + else + _ -> + conn + |> put_resp_content_type("application/json") + |> send_resp(401, Jason.encode!(%{"error" => "invalid_access_token"})) + |> halt() + end + end + + defp get_auth_context(%Plug.Conn{} = conn, type) do + {location_region, location_city, {location_lat, location_lon}} = + get_load_balancer_ip_location(conn) + + %Domain.Auth.Context{ + type: type, + user_agent: Map.get(conn.assigns, :user_agent), + remote_ip: conn.remote_ip, + remote_ip_location_region: location_region, + remote_ip_location_city: location_city, + remote_ip_location_lat: location_lat, + remote_ip_location_lon: location_lon + } + end + + defp get_load_balancer_ip_location(%Plug.Conn{} = conn) do + location_region = + case Plug.Conn.get_req_header(conn, "x-geo-location-region") do + ["" | _] -> nil + [location_region | _] -> location_region + [] -> nil + end + + location_city = + case Plug.Conn.get_req_header(conn, "x-geo-location-city") do + ["" | _] -> nil + [location_city | _] -> location_city + [] -> nil + end + + {location_lat, location_lon} = + case Plug.Conn.get_req_header(conn, "x-geo-location-coordinates") do + ["" | _] -> + {nil, nil} + + ["," | _] -> + {nil, nil} + + [coordinates | _] -> + [lat, lon] = String.split(coordinates, ",", parts: 2) + lat = String.to_float(lat) + lon = String.to_float(lon) + {lat, lon} + + [] -> + {nil, nil} + end + + {location_lat, location_lon} = + Domain.Geo.maybe_put_default_coordinates(location_region, {location_lat, location_lon}) + + {location_region, location_city, {location_lat, location_lon}} + end +end diff --git a/elixir/apps/api/lib/api/router.ex b/elixir/apps/api/lib/api/router.ex new file mode 100644 index 000000000..0f5492a2a --- /dev/null +++ b/elixir/apps/api/lib/api/router.ex @@ -0,0 +1,35 @@ +defmodule API.Router do + use API, :router + + pipeline :api do + plug Plug.Parsers, + parsers: [:json], + pass: ["*/*"], + json_decoder: Phoenix.json_library() + + plug :accepts, ["json"] + plug API.Plugs.Auth + end + + pipeline :public do + plug :accepts, ["html", "xml", "json"] + end + + scope "/", API do + pipe_through :public + + get "/healthz", HealthController, :healthz + end + + scope "/v1", API do + pipe_through :api + + post "/echo", ExampleController, :echo + end + + scope "/integrations", API.Integrations do + scope "/stripe", Stripe do + post "/webhooks", WebhookController, :handle_webhook + end + end +end diff --git a/elixir/apps/api/lib/api/sockets.ex b/elixir/apps/api/lib/api/sockets.ex index b8893103e..04e532353 100644 --- a/elixir/apps/api/lib/api/sockets.ex +++ b/elixir/apps/api/lib/api/sockets.ex @@ -23,6 +23,9 @@ defmodule API.Sockets do def handle_error(conn, :missing_token), do: Plug.Conn.send_resp(conn, 401, "Missing token") + def handle_error(conn, :account_disabled), + do: Plug.Conn.send_resp(conn, 403, "The account is disabled") + def handle_error(conn, :unauthenticated), do: Plug.Conn.send_resp(conn, 403, "Forbidden") diff --git a/elixir/apps/api/mix.exs b/elixir/apps/api/mix.exs index 0ff994185..e49ee9bf6 100644 --- a/elixir/apps/api/mix.exs +++ b/elixir/apps/api/mix.exs @@ -44,6 +44,7 @@ defmodule API.MixProject do # Phoenix deps {:phoenix, "~> 1.7.0"}, + {:phoenix_ecto, "~> 4.4"}, {:plug_cowboy, "~> 2.7"}, # Observability deps diff --git a/elixir/apps/api/test/api/client/channel_test.exs b/elixir/apps/api/test/api/client/channel_test.exs index 197c3dc1d..d2e5e0114 100644 --- a/elixir/apps/api/test/api/client/channel_test.exs +++ b/elixir/apps/api/test/api/client/channel_test.exs @@ -3,15 +3,15 @@ defmodule API.Client.ChannelTest do alias Domain.Mocks.GoogleCloudPlatform setup do - account = Fixtures.Accounts.create_account() - - Fixtures.Config.upsert_configuration( - account: account, - clients_upstream_dns: [ - %{protocol: "ip_port", address: "1.1.1.1"}, - %{protocol: "ip_port", address: "8.8.8.8:53"} - ] - ) + account = + Fixtures.Accounts.create_account( + config: %{ + clients_upstream_dns: [ + %{protocol: "ip_port", address: "1.1.1.1"}, + %{protocol: "ip_port", address: "8.8.8.8:53"} + ] + } + ) actor_group = Fixtures.Actors.create_group(account: account) actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account) diff --git a/elixir/apps/api/test/api/controllers/example_controller_test.exs b/elixir/apps/api/test/api/controllers/example_controller_test.exs new file mode 100644 index 000000000..3fddcfcd8 --- /dev/null +++ b/elixir/apps/api/test/api/controllers/example_controller_test.exs @@ -0,0 +1,22 @@ +defmodule API.ExampleControllerTest do + use API.ConnCase, async: true + + describe "echo/2" do + test "returns error when not authorized", %{conn: conn} do + conn = post(conn, "/v1/echo", %{"message" => "Hello, world!"}) + assert json_response(conn, 401) == %{"error" => "invalid_access_token"} + end + + test "returns 200 OK with the request body", %{conn: conn} do + actor = Fixtures.Actors.create_actor(type: :api_client) + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> post("/v1/echo", Jason.encode!(%{"message" => "Hello, world!"})) + + assert json_response(conn, 200) == %{"message" => "Hello, world!"} + end + end +end diff --git a/elixir/apps/api/test/api/controllers/integrations/stripe/webhook_controller_test.exs b/elixir/apps/api/test/api/controllers/integrations/stripe/webhook_controller_test.exs new file mode 100644 index 000000000..435efa310 --- /dev/null +++ b/elixir/apps/api/test/api/controllers/integrations/stripe/webhook_controller_test.exs @@ -0,0 +1,87 @@ +defmodule API.Integrations.Stripe.WebhookControllerTest do + use API.ConnCase, async: true + import API.Integrations.Stripe.WebhookController + + describe "handle_webhook/2" do + test "returns error when payload is not signed", %{conn: conn} do + conn = post(conn, "/integrations/stripe/webhooks", %{"message" => "Hello, world!"}) + assert response(conn, 400) == "Bad Request: missing signature header" + end + + test "returns error when signature is invalid", %{conn: conn} do + now = System.system_time(:second) + signature = generate_signature(now, "foo") + + conn = + conn + |> put_req_header("content-type", "application/json") + |> put_req_header("stripe-signature", create_signature_header(now, signature)) + |> post("/integrations/stripe/webhooks", "bar") + + assert response(conn, 400) == "Bad Request: invalid signature" + end + + test "returns 200 OK with the request body", %{conn: conn} do + customer_id = "cus_xxx" + account = Fixtures.Accounts.create_account() + + {:ok, account} = + Domain.Accounts.update_account(account, %{ + metadata: %{stripe: %{customer_id: customer_id}} + }) + + Bypass.open() + |> Mocks.Stripe.mock_fetch_customer_endpoint(account) + |> Mocks.Stripe.mock_fetch_product_endpoint("prod_Na6dGcTsmU0I4R") + + payload = + Mocks.Stripe.build_event( + "customer.subscription.updated", + Mocks.Stripe.subscription_object(customer_id, %{}, %{}, 0) + ) + |> Jason.encode!() + + signed_at = System.system_time(:second) - 15 + signature = generate_signature(signed_at, payload) + signature_header = create_signature_header(signed_at, signature) + + conn = + conn + |> put_req_header("content-type", "application/json") + |> put_req_header("stripe-signature", signature_header) + |> post("/integrations/stripe/webhooks", payload) + + assert response(conn, 200) == "" + end + + test "returns error with the signature is too old", %{conn: conn} do + payload = + Mocks.Stripe.build_event( + "customer.subscription.updated", + Mocks.Stripe.subscription_object("cus_xxx", %{}, %{}, 0) + ) + |> Jason.encode!() + + signed_at = System.system_time(:second) - 301 + signature = generate_signature(signed_at, payload) + signature_header = create_signature_header(signed_at, signature) + + conn = + conn + |> put_req_header("content-type", "application/json") + |> put_req_header("stripe-signature", signature_header) + |> post("/integrations/stripe/webhooks", payload) + + assert response(conn, 400) == "Bad Request: expired signature" + end + end + + defp generate_signature(timestamp, payload) do + secret = Domain.Billing.fetch_webhook_signing_secret!() + sign(timestamp, secret, payload) + end + + defp create_signature_header(timestamp, scheme \\ "v1", signature) do + "t=#{timestamp},#{scheme}=#{signature}" + end +end diff --git a/elixir/apps/api/test/support/conn_case.ex b/elixir/apps/api/test/support/conn_case.ex index 4a286f27e..bef9abca8 100644 --- a/elixir/apps/api/test/support/conn_case.ex +++ b/elixir/apps/api/test/support/conn_case.ex @@ -13,6 +13,47 @@ defmodule API.ConnCase do import Plug.Conn import Phoenix.ConnTest import API.ConnCase + + alias Domain.Repo + alias Domain.Fixtures + alias Domain.Mocks end end + + setup _tags do + user_agent = "testing" + + conn = + Phoenix.ConnTest.build_conn() + |> Plug.Conn.put_req_header("user-agent", user_agent) + |> Plug.Test.init_test_session(%{}) + |> Plug.Conn.put_req_header("x-geo-location-region", "UA") + |> Plug.Conn.put_req_header("x-geo-location-city", "Kyiv") + |> Plug.Conn.put_req_header("x-geo-location-coordinates", "50.4333,30.5167") + + conn = %{conn | secret_key_base: API.Endpoint.config(:secret_key_base)} + + {:ok, conn: conn, user_agent: user_agent} + end + + def authorize_conn(conn, %Domain.Actors.Actor{} = actor) do + expires_in = DateTime.utc_now() |> DateTime.add(300, :second) + {"user-agent", user_agent} = List.keyfind(conn.req_headers, "user-agent", 0) + + attrs = %{ + "name" => "conn_case_token", + "expires_at" => expires_in, + "type" => :api_client, + "secret_fragment" => Domain.Crypto.random_token(32, encoder: :hex32), + "account_id" => actor.account_id, + "actor_id" => actor.id, + "created_by_user_agent" => user_agent, + "created_by_remote_ip" => conn.remote_ip + } + + {:ok, token} = Domain.Tokens.create_token(attrs) + encoded_fragment = Domain.Tokens.encode_fragment!(token) + + Plug.Conn.put_req_header(conn, "authorization", "Bearer " <> encoded_fragment) + end end diff --git a/elixir/apps/domain/lib/domain/accounts.ex b/elixir/apps/domain/lib/domain/accounts.ex index 263a0b9e4..44f6aa625 100644 --- a/elixir/apps/domain/lib/domain/accounts.ex +++ b/elixir/apps/domain/lib/domain/accounts.ex @@ -1,7 +1,12 @@ defmodule Domain.Accounts do - alias Domain.{Repo, Validator} + alias Domain.{Repo, Validator, Config, PubSub} alias Domain.Auth - alias Domain.Accounts.{Authorizer, Account} + alias Domain.Accounts.{Account, Features, Authorizer} + + def list_active_accounts do + Account.Query.not_disabled() + |> Repo.list() + end def list_accounts_by_ids(ids) do if Enum.all?(ids, &Validator.valid_uuid?/1) do @@ -13,7 +18,7 @@ defmodule Domain.Accounts do end def fetch_account_by_id(id, %Auth.Subject{} = subject) do - with :ok <- Auth.ensure_has_permissions(subject, Authorizer.view_accounts_permission()), + with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_own_account_permission()), true <- Validator.valid_uuid?(id) do Account.Query.by_id(id) |> Authorizer.for_subject(subject) @@ -25,13 +30,10 @@ defmodule Domain.Accounts do end def fetch_account_by_id_or_slug(id_or_slug, %Auth.Subject{} = subject) do - with :ok <- Auth.ensure_has_permissions(subject, Authorizer.view_accounts_permission()), + with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_own_account_permission()), true <- not is_nil(id_or_slug) do - if Validator.valid_uuid?(id_or_slug) do - Account.Query.by_id(id_or_slug) - else - Account.Query.by_slug(id_or_slug) - end + id_or_slug + |> Account.Query.by_id_or_slug() |> Authorizer.for_subject(subject) |> Repo.fetch() else @@ -44,11 +46,8 @@ defmodule Domain.Accounts do def fetch_account_by_id_or_slug(""), do: {:error, :not_found} def fetch_account_by_id_or_slug(id_or_slug) do - if Validator.valid_uuid?(id_or_slug) do - Account.Query.by_id(id_or_slug) - else - Account.Query.by_slug(id_or_slug) - end + id_or_slug + |> Account.Query.by_id_or_slug() |> Repo.fetch() end @@ -71,6 +70,60 @@ defmodule Domain.Accounts do |> Repo.insert() end + def change_account(%Account{} = account, attrs) do + Account.Changeset.update(account, attrs) + end + + def update_account(%Account{} = account, attrs, %Auth.Subject{} = subject) do + with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_own_account_permission()) do + Account.Query.by_id(account.id) + |> Authorizer.for_subject(subject) + |> Repo.fetch_and_update( + with: fn account -> + changeset = Account.Changeset.update_profile_and_config(account, attrs) + {changeset, execute_after_commit: on_account_update_cb(changeset)} + end + ) + end + end + + def update_account(%Account{} = account, attrs) do + update_account_by_id(account.id, attrs) + end + + def update_account_by_id(id, attrs) do + Account.Query.all() + |> Account.Query.by_id(id) + |> Repo.fetch_and_update( + with: fn account -> + changeset = Account.Changeset.update(account, attrs) + {changeset, execute_after_commit: on_account_update_cb(changeset)} + end + ) + end + + defp on_account_update_cb(changeset) do + if Ecto.Changeset.changed?(changeset, :config) do + &broadcast_config_update_to_account/1 + else + fn _account -> :ok end + end + end + + for feature <- Features.__schema__(:fields) do + def unquote(:"#{feature}_enabled?")(account) do + Config.global_feature_enabled?(unquote(feature)) and + account_feature_enabled?(account, unquote(feature)) + end + end + + defp account_feature_enabled?(account, feature) do + Map.fetch!(account.features || %Features{}, feature) || false + end + + def account_active?(%{deleted_at: nil, disabled_at: nil}), do: true + def account_active?(_account), do: false + def ensure_has_access_to(%Auth.Subject{} = subject, %Account{} = account) do if subject.account.id == account.id do :ok @@ -88,4 +141,23 @@ defmodule Domain.Accounts do slug_candidate end end + + ### PubSub + + defp account_topic(%Account{} = account), do: account_topic(account.id) + defp account_topic(account_id), do: "accounts:#{account_id}" + + def subscribe_to_events_in_account(account_or_id) do + PubSub.subscribe(account_topic(account_or_id)) + end + + defp broadcast_config_update_to_account(%Account{} = account) do + broadcast_to_account(account.id, :config_changed) + end + + defp broadcast_to_account(account_or_id, payload) do + account_or_id + |> account_topic() + |> PubSub.broadcast(payload) + end end diff --git a/elixir/apps/domain/lib/domain/accounts/account.ex b/elixir/apps/domain/lib/domain/accounts/account.ex index 31eb5f6af..024920f32 100644 --- a/elixir/apps/domain/lib/domain/accounts/account.ex +++ b/elixir/apps/domain/lib/domain/accounts/account.ex @@ -5,6 +5,20 @@ defmodule Domain.Accounts.Account do field :name, :string field :slug, :string + # Updated by the billing subscription metadata fields + embeds_one :features, Domain.Accounts.Features, on_replace: :delete + embeds_one :limits, Domain.Accounts.Limits, on_replace: :delete + + embeds_one :config, Domain.Accounts.Config, on_replace: :delete + + embeds_one :metadata, Metadata, primary_key: false, on_replace: :update do + embeds_one :stripe, Stripe, primary_key: false, on_replace: :update do + field :customer_id, :string + field :subscription_id, :string + field :product_name, :string + end + end + # We mention all schemas here to leverage Ecto compile-time reference checks, # because later we will have to shard data by account_id. has_many :actors, Domain.Actors.Actor, where: [deleted_at: nil] @@ -33,6 +47,14 @@ defmodule Domain.Accounts.Account do has_many :tokens, Domain.Tokens.Token, where: [deleted_at: nil] + field :warning, :string + field :warning_delivery_attempts, :integer, default: 0 + field :warning_last_sent_at, :utc_datetime_usec + + field :disabled_reason, :string + field :disabled_at, :utc_datetime_usec + + field :deleted_at, :utc_datetime_usec timestamps() end end diff --git a/elixir/apps/domain/lib/domain/accounts/account/changeset.ex b/elixir/apps/domain/lib/domain/accounts/account/changeset.ex index 3a98cee29..f079731e0 100644 --- a/elixir/apps/domain/lib/domain/accounts/account/changeset.ex +++ b/elixir/apps/domain/lib/domain/accounts/account/changeset.ex @@ -1,6 +1,16 @@ defmodule Domain.Accounts.Account.Changeset do use Domain, :changeset - alias Domain.Accounts.Account + alias Domain.Accounts.{Account, Config, Features, Limits} + + @blacklisted_slugs ~w[ + sign_up signup register + sign_in signin log_in login + sign_out signout + auth authorize authenticate + create update delete claim + account me you + admin user system internal + ] def create(attrs) do %Account{} @@ -8,38 +18,52 @@ defmodule Domain.Accounts.Account.Changeset do |> changeset() end + def update_profile_and_config(%Account{} = account, attrs) do + account + |> cast(attrs, [:name]) + |> validate_name() + |> cast_embed(:config, with: &Config.Changeset.changeset/2) + end + + def update(%Account{} = account, attrs) do + account + |> cast(attrs, [ + :name, + :disabled_reason, + :disabled_at, + :warning, + :warning_delivery_attempts, + :warning_last_sent_at + ]) + |> changeset() + end + defp changeset(changeset) do + changeset + |> validate_name() + |> validate_slug() + |> prepare_changes(&put_default_slug/1) + |> cast_embed(:config, with: &Config.Changeset.changeset/2) + |> cast_embed(:features, with: &Features.Changeset.changeset/2) + |> cast_embed(:limits, with: &Limits.Changeset.changeset/2) + |> cast_embed(:metadata, with: &metadata_changeset/2) + end + + defp validate_name(changeset) do changeset |> validate_required([:name]) |> trim_change(:name) |> validate_length(:name, min: 3, max: 64) - |> prepare_changes(fn changeset -> put_slug_default(changeset) end) - |> downcase_slug() - |> validate_slug() - |> unique_constraint(:slug, name: :accounts_slug_index) - end - - defp put_slug_default(changeset) do - changeset - |> put_default_value(:slug, &Domain.Accounts.generate_unique_slug/0) end defp validate_slug(changeset) do changeset |> validate_length(:slug, min: 3, max: 100) + |> update_change(:slug, &String.downcase/1) |> validate_format(:slug, ~r/^[a-zA-Z0-9_]+$/, message: "can only contain letters, numbers, and underscores" ) - |> validate_exclusion(:slug, [ - "sign_up", - "sign_in", - "sign_out", - "account", - "admin", - "system", - "me", - "you" - ]) + |> validate_exclusion(:slug, @blacklisted_slugs) |> validate_change(:slug, fn field, slug -> if valid_uuid?(slug) do [{field, "cannot be a valid UUID"}] @@ -47,9 +71,21 @@ defmodule Domain.Accounts.Account.Changeset do [] end end) + |> unique_constraint(:slug, name: :accounts_slug_index) end - defp downcase_slug(changeset) do - update_change(changeset, :slug, &String.downcase/1) + defp put_default_slug(changeset) do + put_default_value(changeset, :slug, &Domain.Accounts.generate_unique_slug/0) + end + + def metadata_changeset(metadata \\ %Account.Metadata{}, attrs) do + metadata + |> cast(attrs, []) + |> cast_embed(:stripe, with: &stripe_metadata_changeset/2) + end + + def stripe_metadata_changeset(stripe \\ %Account.Metadata.Stripe{}, attrs) do + stripe + |> cast(attrs, [:customer_id, :subscription_id, :product_name]) end end diff --git a/elixir/apps/domain/lib/domain/accounts/account/query.ex b/elixir/apps/domain/lib/domain/accounts/account/query.ex index 18d44ba11..e51eaae55 100644 --- a/elixir/apps/domain/lib/domain/accounts/account/query.ex +++ b/elixir/apps/domain/lib/domain/accounts/account/query.ex @@ -1,12 +1,20 @@ defmodule Domain.Accounts.Account.Query do use Domain, :query + alias Domain.Validator def all do from(account in Domain.Accounts.Account, as: :account) - # |> where([account: account], is_nil(account.deleted_at)) end - def by_id(queryable \\ all(), id) + def not_deleted(queryable \\ all()) do + where(queryable, [account: account], is_nil(account.deleted_at)) + end + + def not_disabled(queryable \\ not_deleted()) do + where(queryable, [account: account], is_nil(account.disabled_at)) + end + + def by_id(queryable \\ not_deleted(), id) def by_id(queryable, {:in, ids}) do where(queryable, [account: account], account.id in ^ids) @@ -16,7 +24,23 @@ defmodule Domain.Accounts.Account.Query do where(queryable, [account: account], account.id == ^id) end - def by_slug(queryable \\ all(), slug) do + def by_stripe_customer_id(queryable, customer_id) do + where( + queryable, + [account: account], + fragment("?->'stripe'->>'customer_id' = ?", account.metadata, ^customer_id) + ) + end + + def by_slug(queryable \\ not_deleted(), slug) do where(queryable, [account: account], account.slug == ^slug) end + + def by_id_or_slug(queryable \\ not_deleted(), id_or_slug) do + if Validator.valid_uuid?(id_or_slug) do + by_id(queryable, id_or_slug) + else + by_slug(queryable, id_or_slug) + end + end end diff --git a/elixir/apps/domain/lib/domain/accounts/authorizer.ex b/elixir/apps/domain/lib/domain/accounts/authorizer.ex index e79b1f66f..a1bb6648c 100644 --- a/elixir/apps/domain/lib/domain/accounts/authorizer.ex +++ b/elixir/apps/domain/lib/domain/accounts/authorizer.ex @@ -2,17 +2,23 @@ defmodule Domain.Accounts.Authorizer do use Domain.Auth.Authorizer alias Domain.Accounts.Account - def view_accounts_permission, do: build(Account, :view) + def manage_own_account_permission, do: build(Account, :manage_own) @impl Domain.Auth.Authorizer + def list_permissions_for_role(:account_admin_user) do + [ + manage_own_account_permission() + ] + end + def list_permissions_for_role(_) do - [view_accounts_permission()] + [] end @impl Domain.Auth.Authorizer def for_subject(queryable, %Subject{} = subject) do cond do - has_permission?(subject, view_accounts_permission()) -> + has_permission?(subject, manage_own_account_permission()) -> Account.Query.by_id(queryable, subject.account.id) end end diff --git a/elixir/apps/domain/lib/domain/accounts/config.ex b/elixir/apps/domain/lib/domain/accounts/config.ex new file mode 100644 index 000000000..dabf5e9ba --- /dev/null +++ b/elixir/apps/domain/lib/domain/accounts/config.ex @@ -0,0 +1,13 @@ +defmodule Domain.Accounts.Config do + use Domain, :schema + + @primary_key false + embedded_schema do + embeds_many :clients_upstream_dns, ClientsUpstreamDNS, primary_key: false, on_replace: :delete do + field :protocol, Ecto.Enum, values: [:ip_port, :dns_over_tls, :dns_over_http] + field :address, :string + end + end + + def supported_dns_protocols, do: ~w[ip_port]a +end diff --git a/elixir/apps/domain/lib/domain/accounts/config/changeset.ex b/elixir/apps/domain/lib/domain/accounts/config/changeset.ex new file mode 100644 index 000000000..483e99796 --- /dev/null +++ b/elixir/apps/domain/lib/domain/accounts/config/changeset.ex @@ -0,0 +1,79 @@ +defmodule Domain.Accounts.Config.Changeset do + use Domain, :changeset + alias Domain.Types.IPPort + alias Domain.Accounts.Config + + @default_dns_port 53 + + def changeset(config \\ %Config{}, attrs) do + config + |> cast(attrs, []) + |> cast_embed(:clients_upstream_dns, with: &client_upstream_dns_changeset/2) + |> validate_unique_clients_upstream_dns() + end + + defp validate_unique_clients_upstream_dns(changeset) do + with false <- has_errors?(changeset, :clients_upstream_dns), + {_data_or_changes, client_upstream_dns} <- fetch_field(changeset, :clients_upstream_dns) do + addresses = + client_upstream_dns + |> Enum.map(&normalize_dns_address/1) + |> Enum.reject(&is_nil/1) + + if addresses -- Enum.uniq(addresses) == [] do + changeset + else + add_error(changeset, :clients_upstream_dns, "all addresses must be unique") + end + else + _ -> changeset + end + end + + def normalize_dns_address(%Config.ClientsUpstreamDNS{protocol: :ip_port, address: address}) do + case IPPort.cast(address) do + {:ok, address} -> + address + |> IPPort.put_default_port(@default_dns_port) + |> to_string() + + _ -> + address + end + end + + def normalize_dns_address(%Config.ClientsUpstreamDNS{address: address}) do + address + end + + def client_upstream_dns_changeset(client_upstream_dns \\ %Config.ClientsUpstreamDNS{}, attrs) do + client_upstream_dns + |> cast(attrs, [:protocol, :address]) + |> validate_required([:protocol, :address]) + |> trim_change(:address) + |> validate_inclusion(:protocol, Config.supported_dns_protocols(), + message: "this type of DNS provider is not supported yet" + ) + |> validate_address() + end + + defp validate_address(changeset) do + if has_errors?(changeset, :protocol) do + changeset + else + case fetch_field(changeset, :protocol) do + {_changes_or_data, :ip_port} -> validate_ip_port(changeset) + :error -> changeset + end + end + end + + defp validate_ip_port(changeset) do + validate_change(changeset, :address, fn :address, address -> + case IPPort.cast(address) do + {:ok, _ip} -> [] + _ -> [address: "must be a valid IP address"] + end + end) + end +end diff --git a/elixir/apps/domain/lib/domain/accounts/features.ex b/elixir/apps/domain/lib/domain/accounts/features.ex new file mode 100644 index 000000000..cb3dfbb90 --- /dev/null +++ b/elixir/apps/domain/lib/domain/accounts/features.ex @@ -0,0 +1,12 @@ +defmodule Domain.Accounts.Features do + use Domain, :schema + + @primary_key false + embedded_schema do + field :flow_activities, :boolean + field :multi_site_resources, :boolean + field :traffic_filters, :boolean + field :self_hosted_relays, :boolean + field :idp_sync, :boolean + end +end diff --git a/elixir/apps/domain/lib/domain/accounts/features/changeset.ex b/elixir/apps/domain/lib/domain/accounts/features/changeset.ex new file mode 100644 index 000000000..0f7cdf15b --- /dev/null +++ b/elixir/apps/domain/lib/domain/accounts/features/changeset.ex @@ -0,0 +1,11 @@ +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]a + + def changeset(features \\ %Features{}, attrs) do + features + |> cast(attrs, @fields) + end +end diff --git a/elixir/apps/domain/lib/domain/accounts/limits.ex b/elixir/apps/domain/lib/domain/accounts/limits.ex new file mode 100644 index 000000000..f8d3032a1 --- /dev/null +++ b/elixir/apps/domain/lib/domain/accounts/limits.ex @@ -0,0 +1,11 @@ +defmodule Domain.Accounts.Limits do + use Domain, :schema + + @primary_key false + embedded_schema do + field :monthly_active_users_count, :integer + field :service_accounts_count, :integer + field :gateway_groups_count, :integer + field :account_admin_users_count, :integer + end +end diff --git a/elixir/apps/domain/lib/domain/accounts/limits/changeset.ex b/elixir/apps/domain/lib/domain/accounts/limits/changeset.ex new file mode 100644 index 000000000..c20b8efd9 --- /dev/null +++ b/elixir/apps/domain/lib/domain/accounts/limits/changeset.ex @@ -0,0 +1,15 @@ +defmodule Domain.Accounts.Limits.Changeset do + use Domain, :changeset + alias Domain.Accounts.Limits + + @fields ~w[monthly_active_users_count service_accounts_count gateway_groups_count account_admin_users_count]a + + def changeset(limits \\ %Limits{}, attrs) do + limits + |> cast(attrs, @fields) + |> validate_number(:monthly_active_users_count, greater_than_or_equal_to: 0) + |> validate_number(:service_accounts_count, greater_than_or_equal_to: 0) + |> validate_number(:gateway_groups_count, greater_than_or_equal_to: 0) + |> validate_number(:account_admin_users_count, greater_than_or_equal_to: 0) + end +end diff --git a/elixir/apps/domain/lib/domain/actors.ex b/elixir/apps/domain/lib/domain/actors.ex index a4c8f8a42..19e46539d 100644 --- a/elixir/apps/domain/lib/domain/actors.ex +++ b/elixir/apps/domain/lib/domain/actors.ex @@ -2,7 +2,7 @@ defmodule Domain.Actors do alias Domain.Actors.Membership alias Web.Clients alias Domain.{Repo, Validator, PubSub} - alias Domain.{Accounts, Auth, Tokens, Clients, Policies} + alias Domain.{Accounts, Auth, Tokens, Clients, Policies, Billing} alias Domain.Actors.{Authorizer, Actor, Group} require Ecto.Query @@ -301,6 +301,20 @@ defmodule Domain.Actors do # Actors + def count_account_admin_users_for_account(%Accounts.Account{} = account) do + Actor.Query.not_disabled() + |> Actor.Query.by_account_id(account.id) + |> Actor.Query.by_type(:account_admin_user) + |> Repo.aggregate(:count) + end + + def count_service_accounts_for_account(%Accounts.Account{} = account) do + Actor.Query.not_disabled() + |> Actor.Query.by_account_id(account.id) + |> Actor.Query.by_type(:service_account) + |> Repo.aggregate(:count) + end + def fetch_actors_count_by_type(type, %Auth.Subject{} = subject) do with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_actors_permission()) do Actor.Query.by_type(type) @@ -367,12 +381,47 @@ defmodule Domain.Actors do def create_actor(%Accounts.Account{} = account, attrs, %Auth.Subject{} = subject) do with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_actors_permission()), - :ok <- Accounts.ensure_has_access_to(subject, account) do - Actor.Changeset.create(account.id, attrs, subject) - |> Repo.insert() + :ok <- Accounts.ensure_has_access_to(subject, account), + changeset = Actor.Changeset.create(account.id, attrs, subject), + :ok <- ensure_billing_limits_not_exceeded(account, changeset) do + Repo.insert(changeset) end end + defp ensure_billing_limits_not_exceeded(account, %{valid?: true} = changeset) do + case Ecto.Changeset.fetch_field!(changeset, :type) do + :service_account -> + if Billing.can_create_service_accounts?(account) do + :ok + else + {:error, :service_accounts_limit_reached} + end + + :account_admin_user -> + if Billing.can_create_users?(account) and Billing.can_create_admin_users?(account) do + :ok + else + {:error, :seats_limit_reached} + end + + :account_user -> + if Billing.can_create_users?(account) do + :ok + else + {:error, :seats_limit_reached} + end + + _other -> + :ok + end + end + + defp ensure_billing_limits_not_exceeded(_account, _changeset) do + # we return :ok because we want Repo.insert() call to still put action and + # rest of possible metadata if there are validation errors + :ok + end + def create_actor(%Accounts.Account{} = account, attrs) do Actor.Changeset.create(account.id, attrs) |> Repo.insert() diff --git a/elixir/apps/domain/lib/domain/actors/actor/query.ex b/elixir/apps/domain/lib/domain/actors/actor/query.ex index 5befce710..d34c9dbd7 100644 --- a/elixir/apps/domain/lib/domain/actors/actor/query.ex +++ b/elixir/apps/domain/lib/domain/actors/actor/query.ex @@ -94,6 +94,17 @@ defmodule Domain.Actors.Actor.Query do ) end + def with_joined_clients(queryable \\ not_deleted()) do + join( + queryable, + :left, + [actors: actors], + clients in ^Domain.Clients.Client.Query.not_deleted(), + on: clients.actor_id == actors.id, + as: :clients + ) + end + def lock(queryable \\ not_deleted()) do lock(queryable, "FOR UPDATE") end diff --git a/elixir/apps/domain/lib/domain/application.ex b/elixir/apps/domain/lib/domain/application.ex index 256b2bc97..31850ce8e 100644 --- a/elixir/apps/domain/lib/domain/application.ex +++ b/elixir/apps/domain/lib/domain/application.ex @@ -36,7 +36,8 @@ defmodule Domain.Application do Domain.Auth, Domain.Relays, Domain.Gateways, - Domain.Clients + Domain.Clients, + Domain.Billing # Observability # Domain.Telemetry diff --git a/elixir/apps/domain/lib/domain/auth.ex b/elixir/apps/domain/lib/domain/auth.ex index ad2961801..8e5e50ebd 100644 --- a/elixir/apps/domain/lib/domain/auth.ex +++ b/elixir/apps/domain/lib/domain/auth.ex @@ -102,8 +102,16 @@ defmodule Domain.Auth do # Providers - def list_provider_adapters do - Adapters.list_adapters() + def list_user_provisioned_provider_adapters!(%Accounts.Account{} = account) do + idp_sync_enabled? = Accounts.idp_sync_enabled?(account) + + Adapters.list_user_provisioned_adapters!() + |> Map.keys() + |> Enum.map(fn adapter -> + capabilities = Adapters.fetch_capabilities!(adapter) + requires_idp_sync_feature? = capabilities[:default_provisioner] == :custom + {adapter, enabled: idp_sync_enabled? or not requires_idp_sync_feature?} + end) end def fetch_provider_by_id(id, %Subject{} = subject, opts \\ []) do diff --git a/elixir/apps/domain/lib/domain/auth/adapters.ex b/elixir/apps/domain/lib/domain/auth/adapters.ex index 898ff021c..317be2b38 100644 --- a/elixir/apps/domain/lib/domain/auth/adapters.ex +++ b/elixir/apps/domain/lib/domain/auth/adapters.ex @@ -22,10 +22,14 @@ defmodule Domain.Auth.Adapters do Supervisor.init(@adapter_modules, strategy: :one_for_one) end - def list_adapters do + def list_all_adapters! do + Map.keys(@adapters) + end + + def list_user_provisioned_adapters! do enabled_adapters = Domain.Config.compile_config!(:auth_provider_adapters) enabled_idp_adapters = enabled_adapters -- ~w[email userpass]a - {:ok, Map.take(@adapters, enabled_idp_adapters)} + Map.take(@adapters, enabled_idp_adapters) end def fetch_capabilities!(%Provider{} = provider) do diff --git a/elixir/apps/domain/lib/domain/auth/adapters/google_workspace/jobs.ex b/elixir/apps/domain/lib/domain/auth/adapters/google_workspace/jobs.ex index a035f6aa6..fc5fca393 100644 --- a/elixir/apps/domain/lib/domain/auth/adapters/google_workspace/jobs.ex +++ b/elixir/apps/domain/lib/domain/auth/adapters/google_workspace/jobs.ex @@ -39,6 +39,7 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace.Jobs do Logger.debug("Syncing #{length(providers)} Google Workspace providers") providers + |> Domain.Repo.preload(:account) |> Enum.chunk_every(5) |> Enum.each(fn providers -> Enum.map(providers, fn provider -> @@ -46,7 +47,8 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace.Jobs do access_token = provider.adapter_state["access_token"] - with {:ok, users} <- GoogleWorkspace.APIClient.list_users(access_token), + with true <- Domain.Accounts.idp_sync_enabled?(provider.account), + {:ok, users} <- GoogleWorkspace.APIClient.list_users(access_token), {:ok, organization_units} <- GoogleWorkspace.APIClient.list_organization_units(access_token), {:ok, groups} <- GoogleWorkspace.APIClient.list_groups(access_token), @@ -116,6 +118,15 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace.Jobs do ) end else + false -> + Auth.Provider.Changeset.sync_failed( + provider, + "IdP sync is not enabled in your subscription plan" + ) + |> Domain.Repo.update!() + + :ok + {:error, {status, %{"error" => %{"message" => message}}}} -> provider = Auth.Provider.Changeset.sync_failed(provider, message) diff --git a/elixir/apps/domain/lib/domain/auth/adapters/microsoft_entra/jobs.ex b/elixir/apps/domain/lib/domain/auth/adapters/microsoft_entra/jobs.ex index a04dd3b49..d3e38cfe0 100644 --- a/elixir/apps/domain/lib/domain/auth/adapters/microsoft_entra/jobs.ex +++ b/elixir/apps/domain/lib/domain/auth/adapters/microsoft_entra/jobs.ex @@ -39,6 +39,7 @@ defmodule Domain.Auth.Adapters.MicrosoftEntra.Jobs do Logger.debug("Syncing #{length(providers)} Microsoft Entra providers") providers + |> Domain.Repo.preload(:account) |> Enum.chunk_every(5) |> Enum.each(fn providers -> Enum.map(providers, fn provider -> @@ -53,7 +54,8 @@ defmodule Domain.Auth.Adapters.MicrosoftEntra.Jobs do access_token = provider.adapter_state["access_token"] - with {:ok, users} <- MicrosoftEntra.APIClient.list_users(access_token), + with true <- Domain.Accounts.idp_sync_enabled?(provider.account), + {:ok, users} <- MicrosoftEntra.APIClient.list_users(access_token), {:ok, groups} <- MicrosoftEntra.APIClient.list_groups(access_token), {:ok, tuples} <- list_membership_tuples(access_token, groups) do identities_attrs = map_identity_attrs(users) @@ -88,6 +90,15 @@ defmodule Domain.Auth.Adapters.MicrosoftEntra.Jobs do ) end else + false -> + Auth.Provider.Changeset.sync_failed( + provider, + "IdP sync is not enabled in your subscription plan" + ) + |> Domain.Repo.update!() + + :ok + {:error, {status, %{"error" => %{"message" => message}}}} -> provider = Auth.Provider.Changeset.sync_failed(provider, message) diff --git a/elixir/apps/domain/lib/domain/auth/adapters/okta/jobs.ex b/elixir/apps/domain/lib/domain/auth/adapters/okta/jobs.ex index c73d05aca..40b831573 100644 --- a/elixir/apps/domain/lib/domain/auth/adapters/okta/jobs.ex +++ b/elixir/apps/domain/lib/domain/auth/adapters/okta/jobs.ex @@ -39,10 +39,21 @@ defmodule Domain.Auth.Adapters.Okta.Jobs do Logger.debug("Syncing #{length(providers)} Okta providers") providers + |> Domain.Repo.preload(:account) |> Enum.chunk_every(5) |> Enum.each(fn providers -> Enum.map(providers, fn provider -> - sync_provider_directory(provider) + if Domain.Accounts.idp_sync_enabled?(provider.account) do + sync_provider_directory(provider) + else + Auth.Provider.Changeset.sync_failed( + provider, + "IdP sync is not enabled in your subscription plan" + ) + |> Domain.Repo.update!() + + :ok + end end) end) end diff --git a/elixir/apps/domain/lib/domain/auth/provider/changeset.ex b/elixir/apps/domain/lib/domain/auth/provider/changeset.ex index ae1ade9af..8042a49c6 100644 --- a/elixir/apps/domain/lib/domain/auth/provider/changeset.ex +++ b/elixir/apps/domain/lib/domain/auth/provider/changeset.ex @@ -1,7 +1,7 @@ defmodule Domain.Auth.Provider.Changeset do use Domain, :changeset alias Domain.Accounts - alias Domain.Auth.{Subject, Provider} + alias Domain.Auth.{Subject, Provider, Adapters} @create_fields ~w[id name adapter provisioner adapter_config adapter_state disabled_at]a @update_fields ~w[name adapter_config last_syncs_failed last_sync_error adapter_state provisioner disabled_at deleted_at]a @@ -15,10 +15,23 @@ defmodule Domain.Auth.Provider.Changeset do end def create(%Accounts.Account{} = account, attrs) do + all_adapters = Adapters.list_all_adapters!() + + allowed_adapters = + if Accounts.idp_sync_enabled?(account) do + all_adapters + else + Enum.reject(all_adapters, fn adapter -> + capabilities = Adapters.fetch_capabilities!(adapter) + capabilities[:default_provisioner] == :custom + end) + end + %Provider{} |> cast(attrs, @create_fields) |> put_change(:account_id, account.id) |> changeset() + |> validate_inclusion(:adapter, allowed_adapters) |> put_change(:created_by, :system) end diff --git a/elixir/apps/domain/lib/domain/auth/roles.ex b/elixir/apps/domain/lib/domain/auth/roles.ex index a1b8dda8a..4c2904b22 100644 --- a/elixir/apps/domain/lib/domain/auth/roles.ex +++ b/elixir/apps/domain/lib/domain/auth/roles.ex @@ -6,7 +6,7 @@ defmodule Domain.Auth.Roles do Domain.Accounts.Authorizer, Domain.Actors.Authorizer, Domain.Auth.Authorizer, - Domain.Config.Authorizer, + Domain.Billing.Authorizer, Domain.Clients.Authorizer, Domain.Gateways.Authorizer, Domain.Policies.Authorizer, diff --git a/elixir/apps/domain/lib/domain/billing.ex b/elixir/apps/domain/lib/domain/billing.ex new file mode 100644 index 000000000..992cb46e1 --- /dev/null +++ b/elixir/apps/domain/lib/domain/billing.ex @@ -0,0 +1,366 @@ +defmodule Domain.Billing do + use Supervisor + alias Domain.{Auth, Accounts, Actors, Clients, Gateways} + alias Domain.Billing.{Authorizer, Jobs} + alias Domain.Billing.Stripe.APIClient + require Logger + + def start_link(opts) do + Supervisor.start_link(__MODULE__, opts, name: __MODULE__) + end + + def init(_opts) do + children = [ + APIClient, + {Domain.Jobs, Jobs} + ] + + if enabled?() do + Supervisor.init(children, strategy: :one_for_one) + else + :ignore + end + end + + def enabled? do + fetch_config!(:enabled) + end + + def fetch_webhook_signing_secret! do + fetch_config!(:webhook_signing_secret) + end + + def account_provisioned?(%Accounts.Account{metadata: %{stripe: %{customer_id: customer_id}}}) + when not is_nil(customer_id) do + enabled?() + end + + def account_provisioned?(_account) do + false + end + + def seats_limit_exceeded?(%Accounts.Account{} = account, active_users_count) do + not is_nil(account.limits.monthly_active_users_count) and + active_users_count > account.limits.monthly_active_users_count + end + + def can_create_users?(%Accounts.Account{} = account) do + active_users_count = Clients.count_1m_active_users_for_account(account) + + Accounts.account_active?(account) and + (is_nil(account.limits.monthly_active_users_count) or + active_users_count < account.limits.monthly_active_users_count) + end + + def service_accounts_limit_exceeded?(%Accounts.Account{} = account, service_accounts_count) do + not is_nil(account.limits.service_accounts_count) and + service_accounts_count > account.limits.service_accounts_count + end + + def can_create_service_accounts?(%Accounts.Account{} = account) do + service_accounts_count = Actors.count_service_accounts_for_account(account) + + Accounts.account_active?(account) and + (is_nil(account.limits.service_accounts_count) or + service_accounts_count < account.limits.service_accounts_count) + end + + def gateway_groups_limit_exceeded?(%Accounts.Account{} = account, gateway_groups_count) do + not is_nil(account.limits.gateway_groups_count) and + gateway_groups_count > account.limits.gateway_groups_count + end + + def can_create_gateway_groups?(%Accounts.Account{} = account) do + gateway_groups_count = Gateways.count_groups_for_account(account) + + Accounts.account_active?(account) and + (is_nil(account.limits.gateway_groups_count) or + gateway_groups_count < account.limits.gateway_groups_count) + end + + def admins_limit_exceeded?(%Accounts.Account{} = account, account_admins_count) do + not is_nil(account.limits.account_admin_users_count) and + account_admins_count > account.limits.account_admin_users_count + end + + def can_create_admin_users?(%Accounts.Account{} = account) do + account_admins_count = Actors.count_account_admin_users_for_account(account) + + Accounts.account_active?(account) and + (is_nil(account.limits.account_admin_users_count) or + account_admins_count < account.limits.account_admin_users_count) + end + + def provision_account(%Accounts.Account{} = account) do + secret_key = fetch_config!(:secret_key) + default_price_id = fetch_config!(:default_price_id) + + with true <- enabled?(), + true <- not account_provisioned?(account), + {:ok, %{"id" => customer_id}} <- + APIClient.create_customer(secret_key, account.id, account.name), + {:ok, %{"id" => subscription_id}} <- + APIClient.create_subscription(secret_key, customer_id, default_price_id) do + Accounts.update_account(account, %{ + metadata: %{ + stripe: %{ + customer_id: customer_id, + subscription_id: subscription_id + } + } + }) + else + false -> + {:ok, account} + + {:ok, {status, body}} -> + :ok = Logger.error("Stripe API call failed", status: status, body: inspect(body)) + {:error, :retry_later} + + {:error, reason} -> + :ok = Logger.error("Stripe API call failed", reason: inspect(reason)) + {:error, :retry_later} + end + end + + def billing_portal_url(%Accounts.Account{} = account, return_url, %Auth.Subject{} = subject) do + secret_key = fetch_config!(:secret_key) + + with :ok <- + Auth.ensure_has_permissions( + subject, + Authorizer.manage_own_account_billing_permission() + ), + true <- account_provisioned?(account), + {:ok, %{"url" => url}} <- + APIClient.create_billing_portal_session( + secret_key, + account.metadata.stripe.customer_id, + return_url + ) do + {:ok, url} + else + false -> {:error, :account_not_provisioned} + {:error, reason} -> {:error, reason} + end + end + + def handle_events(events) when is_list(events) do + Enum.each(events, &handle_event/1) + end + + # subscription is ended or deleted + defp handle_event(%{ + "object" => "event", + "data" => %{ + "object" => %{ + "customer" => customer_id + } + }, + "type" => "customer.subscription.deleted" + }) do + update_account_by_stripe_customer_id(customer_id, %{ + disabled_at: DateTime.utc_now(), + disabled_reason: "Stripe subscription deleted" + }) + |> case do + {:ok, _account} -> + :ok + + {:error, reason} -> + :ok = + Logger.error("Failed to update account on Stripe subscription event", + customer_id: customer_id, + reason: inspect(reason) + ) + + :error + end + end + + # subscription is paused + defp handle_event(%{ + "object" => "event", + "data" => %{ + "object" => %{ + "customer" => customer_id, + "pause_collection" => %{ + "behavior" => "void" + } + } + }, + "type" => "customer.subscription.updated" + }) do + update_account_by_stripe_customer_id(customer_id, %{ + disabled_at: DateTime.utc_now(), + disabled_reason: "Stripe subscription paused" + }) + |> case do + {:ok, _account} -> + :ok + + {:error, reason} -> + :ok = + Logger.error("Failed to update account on Stripe subscription event", + customer_id: customer_id, + reason: inspect(reason) + ) + + :error + end + end + + defp handle_event(%{ + "object" => "event", + "data" => %{ + "object" => %{ + "customer" => customer_id + } + }, + "type" => "customer.subscription.paused" + }) do + update_account_by_stripe_customer_id(customer_id, %{ + disabled_at: DateTime.utc_now(), + disabled_reason: "Stripe subscription paused" + }) + |> case do + {:ok, _account} -> + :ok + + {:error, reason} -> + :ok = + Logger.error("Failed to update account on Stripe subscription event", + customer_id: customer_id, + reason: inspect(reason) + ) + + :error + end + end + + # subscription is resumed, created or updated + defp handle_event(%{ + "object" => "event", + "data" => %{ + "object" => %{ + "id" => subscription_id, + "customer" => customer_id, + "metadata" => subscription_metadata, + "items" => %{ + "data" => [ + %{ + "plan" => %{ + "product" => product_id + }, + "quantity" => quantity + } + ] + } + } + }, + "type" => "customer.subscription." <> _ + }) do + secret_key = fetch_config!(:secret_key) + + {:ok, %{"name" => product_name, "metadata" => product_metadata}} = + APIClient.fetch_product(secret_key, product_id) + + attrs = + account_update_attrs(quantity, product_metadata, subscription_metadata) + |> Map.put(:metadata, %{ + stripe: %{ + subscription_id: subscription_id, + product_name: product_name + } + }) + |> Map.put(:disabled_at, nil) + |> Map.put(:disabled_reason, nil) + + update_account_by_stripe_customer_id(customer_id, attrs) + |> case do + {:ok, _account} -> + :ok + + {:error, reason} -> + :ok = + Logger.error("Failed to update account on Stripe subscription event", + customer_id: customer_id, + reason: inspect(reason) + ) + + :error + end + end + + defp handle_event(%{"object" => "event", "data" => %{}}) do + :ok + end + + defp update_account_by_stripe_customer_id(customer_id, attrs) do + secret_key = fetch_config!(:secret_key) + + with {:ok, %{"metadata" => %{"account_id" => account_id}}} <- + APIClient.fetch_customer(secret_key, customer_id) do + Accounts.update_account_by_id(account_id, attrs) + else + {:ok, params} -> + :ok = + Logger.error("Stripe customer does not have account_id in metadata", + metadata: inspect(params["metadata"]) + ) + + {:error, :retry_later} + + {:ok, {status, body}} -> + :ok = Logger.error("Can not fetch Stripe customer", status: status, body: inspect(body)) + {:error, :retry_later} + + {:error, reason} -> + :ok = Logger.error("Can not fetch Stripe customer", reason: inspect(reason)) + {:error, :retry_later} + end + end + + defp account_update_attrs(quantity, product_metadata, subscription_metadata) do + limit_fields = Accounts.Limits.__schema__(:fields) |> Enum.map(&to_string/1) + + features_and_limits = + Map.merge(product_metadata, subscription_metadata) + |> Enum.flat_map(fn + {feature, "true"} -> + [{feature, true}] + + {feature, "false"} -> + [{feature, false}] + + {key, value} -> + if key in limit_fields do + [{key, cast_limit(value)}] + else + [] + end + end) + |> Enum.into(%{}) + + {monthly_active_users_count, features_and_limits} = + Map.pop(features_and_limits, "monthly_active_users_count", quantity) + + {limits, features} = Map.split(features_and_limits, limit_fields) + + limits = Map.merge(limits, %{"monthly_active_users_count" => monthly_active_users_count}) + + %{ + features: features, + limits: limits + } + end + + defp cast_limit(number) when is_number(number), do: number + defp cast_limit("unlimited"), do: nil + defp cast_limit(binary) when is_binary(binary), do: String.to_integer(binary) + + defp fetch_config!(key) do + Domain.Config.fetch_env!(:domain, __MODULE__) + |> Keyword.fetch!(key) + end +end diff --git a/elixir/apps/domain/lib/domain/config/authorizer.ex b/elixir/apps/domain/lib/domain/billing/authorizer.ex similarity index 50% rename from elixir/apps/domain/lib/domain/config/authorizer.ex rename to elixir/apps/domain/lib/domain/billing/authorizer.ex index 088a429a9..22a8326c6 100644 --- a/elixir/apps/domain/lib/domain/config/authorizer.ex +++ b/elixir/apps/domain/lib/domain/billing/authorizer.ex @@ -1,13 +1,13 @@ -defmodule Domain.Config.Authorizer do +defmodule Domain.Billing.Authorizer do use Domain.Auth.Authorizer - alias Domain.Config.Configuration + alias Domain.Billing - def manage_permission, do: build(Configuration, :manage) + def manage_own_account_billing_permission, do: build(Billing, :manage_own) @impl Domain.Auth.Authorizer def list_permissions_for_role(:account_admin_user) do [ - manage_permission() + manage_own_account_billing_permission() ] end diff --git a/elixir/apps/domain/lib/domain/billing/jobs.ex b/elixir/apps/domain/lib/domain/billing/jobs.ex new file mode 100644 index 000000000..87c888bd2 --- /dev/null +++ b/elixir/apps/domain/lib/domain/billing/jobs.ex @@ -0,0 +1,84 @@ +defmodule Domain.Billing.Jobs do + use Domain.Jobs.Recurrent, otp_app: :domain + alias Domain.{Accounts, Billing, Actors, Clients, Gateways} + + every minutes(30), :check_account_limits do + {:ok, accounts} = Accounts.list_active_accounts() + + Enum.each(accounts, fn account -> + if Billing.enabled?() and Billing.account_provisioned?(account) do + [] + |> check_seats_limit(account) + |> check_service_accounts_limit(account) + |> check_gateway_groups_limit(account) + |> check_admin_limit(account) + |> case do + [] -> + {:ok, _account} = + Accounts.update_account(account, %{ + warning: nil, + warning_delivery_attempts: 0, + warning_last_sent_at: nil + }) + + :ok + + limits_exceeded -> + warning = + "You have exceeded the following limits: #{Enum.join(limits_exceeded, ", ")}" + + {:ok, _account} = + Accounts.update_account(account, %{ + warning: warning, + warning_delivery_attempts: 0, + warning_last_sent_at: DateTime.utc_now() + }) + + :ok + end + else + :ok + end + end) + end + + defp check_seats_limit(limits_exceeded, account) do + active_users_count = Clients.count_1m_active_users_for_account(account) + + if Billing.seats_limit_exceeded?(account, active_users_count) do + limits_exceeded ++ ["monthly active users"] + else + limits_exceeded + end + end + + defp check_service_accounts_limit(limits_exceeded, account) do + service_accounts_count = Actors.count_service_accounts_for_account(account) + + if Billing.service_accounts_limit_exceeded?(account, service_accounts_count) do + limits_exceeded ++ ["service accounts"] + else + limits_exceeded + end + end + + defp check_gateway_groups_limit(limits_exceeded, account) do + gateway_groups_count = Gateways.count_groups_for_account(account) + + if Billing.gateway_groups_limit_exceeded?(account, gateway_groups_count) do + limits_exceeded ++ ["sites"] + else + limits_exceeded + end + end + + defp check_admin_limit(limits_exceeded, account) do + account_admins_count = Actors.count_account_admin_users_for_account(account) + + if Billing.admins_limit_exceeded?(account, account_admins_count) do + limits_exceeded ++ ["account admins"] + else + limits_exceeded + end + end +end diff --git a/elixir/apps/domain/lib/domain/billing/stripe/api_client.ex b/elixir/apps/domain/lib/domain/billing/stripe/api_client.ex new file mode 100644 index 000000000..094fc06ea --- /dev/null +++ b/elixir/apps/domain/lib/domain/billing/stripe/api_client.ex @@ -0,0 +1,104 @@ +defmodule Domain.Billing.Stripe.APIClient do + use Supervisor + + @pool_name __MODULE__.Finch + + def start_link(_init_arg) do + Supervisor.start_link(__MODULE__, nil, name: __MODULE__) + end + + @impl true + def init(_init_arg) do + children = [ + {Finch, + name: @pool_name, + pools: %{ + default: pool_opts() + }} + ] + + Supervisor.init(children, strategy: :one_for_one) + end + + defp pool_opts do + transport_opts = fetch_config!(:finch_transport_opts) + [conn_opts: [transport_opts: transport_opts]] + end + + def create_customer(api_token, id, name) do + body = + URI.encode_query( + %{ + "name" => name, + "metadata[account_id]" => id + }, + :www_form + ) + + request(api_token, :post, "customers", body) + end + + def fetch_customer(api_token, customer_id) do + request(api_token, :get, "customers/#{customer_id}", "") + end + + def fetch_product(api_token, product_id) do + request(api_token, :get, "products/#{product_id}", "") + end + + def create_billing_portal_session(api_token, customer_id, return_url) do + body = URI.encode_query(%{"customer" => customer_id, "return_url" => return_url}, :www_form) + request(api_token, :post, "billing_portal/sessions", body) + end + + def create_subscription(api_token, customer_id, price_id) do + body = + URI.encode_query( + %{ + "customer" => customer_id, + "items[0][price]" => price_id + }, + :www_form + ) + + request(api_token, :post, "subscriptions", body) + end + + def request(api_token, method, path, body) do + endpoint = fetch_config!(:endpoint) + uri = URI.parse("#{endpoint}/v1/#{path}") + + headers = [ + {"Authorization", "Bearer #{api_token}"}, + {"Content-Type", "application/x-www-form-urlencoded"}, + {"Stripe-Version", "2023-10-16"} + ] + + Finch.build(method, uri, headers, body) + |> Finch.request(@pool_name) + |> case do + {:ok, %Finch.Response{body: response, status: status}} when status in 200..299 -> + {:ok, Jason.decode!(response)} + + {:ok, %Finch.Response{status: status}} when status in 500..599 -> + {:error, :retry_later} + + {:ok, %Finch.Response{body: response, status: status}} -> + case Jason.decode(response) do + {:ok, json_response} -> + {:error, {status, json_response}} + + _error -> + {:error, {status, response}} + end + + {:error, reason} -> + {:error, reason} + end + end + + defp fetch_config!(key) do + Domain.Config.fetch_env!(:domain, __MODULE__) + |> Keyword.fetch!(key) + end +end diff --git a/elixir/apps/domain/lib/domain/clients.ex b/elixir/apps/domain/lib/domain/clients.ex index d3cd214cb..6f8a87d2c 100644 --- a/elixir/apps/domain/lib/domain/clients.ex +++ b/elixir/apps/domain/lib/domain/clients.ex @@ -22,6 +22,15 @@ defmodule Domain.Clients do |> Repo.aggregate(:count) end + def count_1m_active_users_for_account(%Accounts.Account{} = account) do + Client.Query.by_account_id(account.id) + |> Client.Query.by_last_seen_within(1, "month") + |> Client.Query.select_distinct_actor_id() + |> Client.Query.only_for_active_actors() + |> Client.Query.by_actor_type({:in, [:account_user, :account_admin_user]}) + |> Repo.aggregate(:count) + end + def count_by_actor_id(actor_id) do Client.Query.by_actor_id(actor_id) |> Repo.aggregate(:count) diff --git a/elixir/apps/domain/lib/domain/clients/client/query.ex b/elixir/apps/domain/lib/domain/clients/client/query.ex index fd8fc1451..1205e68e1 100644 --- a/elixir/apps/domain/lib/domain/clients/client/query.ex +++ b/elixir/apps/domain/lib/domain/clients/client/query.ex @@ -18,6 +18,18 @@ defmodule Domain.Clients.Client.Query do where(queryable, [clients: clients], clients.actor_id == ^actor_id) end + def only_for_active_actors(queryable \\ not_deleted()) do + queryable + |> with_joined_actor() + |> where([actor: actor], is_nil(actor.disabled_at)) + end + + def by_actor_type(queryable \\ not_deleted(), {:in, types}) do + queryable + |> with_joined_actor() + |> where([actor: actor], actor.type in ^types) + end + def by_account_id(queryable \\ not_deleted(), account_id) do where(queryable, [clients: clients], clients.account_id == ^account_id) end @@ -26,6 +38,16 @@ defmodule Domain.Clients.Client.Query do where(queryable, [clients: clients], clients.last_used_token_id == ^last_used_token_id) end + def by_last_seen_within(queryable \\ not_deleted(), period, unit) do + where(queryable, [clients: clients], clients.last_seen_at > ago(^period, ^unit)) + end + + def select_distinct_actor_id(queryable \\ not_deleted()) do + queryable + |> select([clients: clients], clients.actor_id) + |> distinct(true) + end + def returning_not_deleted(queryable \\ not_deleted()) do select(queryable, [clients: clients], clients) end @@ -40,14 +62,25 @@ defmodule Domain.Clients.Client.Query do ) end - def with_preloaded_actor(queryable \\ not_deleted()) do + def with_joined_actor(queryable \\ not_deleted()) do with_named_binding(queryable, :actor, fn queryable, binding -> - queryable - |> join(:inner, [clients: clients], actor in assoc(clients, ^binding), as: ^binding) - |> preload([clients: clients, actor: actor], actor: actor) + join( + queryable, + :inner, + [clients: clients], + actor in ^Domain.Actors.Actor.Query.not_deleted(), + on: clients.actor_id == actor.id, + as: ^binding + ) end) end + def with_preloaded_actor(queryable \\ not_deleted()) do + queryable + |> with_joined_actor() + |> preload([clients: clients, actor: actor], actor: actor) + end + def with_preloaded_identity(queryable \\ not_deleted()) do with_named_binding(queryable, :identity, fn queryable, binding -> queryable diff --git a/elixir/apps/domain/lib/domain/config.ex b/elixir/apps/domain/lib/domain/config.ex index b5911f863..d5ab724ab 100644 --- a/elixir/apps/domain/lib/domain/config.ex +++ b/elixir/apps/domain/lib/domain/config.ex @@ -1,8 +1,5 @@ defmodule Domain.Config do - alias Domain.{Repo, Auth, PubSub} - alias Domain.Config.Authorizer alias Domain.Config.{Definition, Definitions, Validator, Errors, Fetcher} - alias Domain.Config.Configuration def fetch_resolved_configs!(account_id, keys, opts \\ []) do for {key, {_source, value}} <- @@ -12,8 +9,9 @@ defmodule Domain.Config do end end - def fetch_resolved_configs_with_sources!(account_id, keys, opts \\ []) do - {db_config, env_config} = maybe_load_sources(account_id, opts, keys) + def fetch_resolved_configs_with_sources!(_account_id, keys, _opts \\ []) do + db_config = %{} + env_config = System.get_env() for key <- keys, into: %{} do case Fetcher.fetch_source_and_config(Definitions, key, db_config, env_config) do @@ -26,26 +24,6 @@ defmodule Domain.Config do end end - defp maybe_load_sources(account_id, opts, keys) when is_list(keys) do - ignored_sources = Keyword.get(opts, :ignore_sources, []) |> List.wrap() - - one_of_keys_is_stored_in_db? = - Enum.any?(keys, &(&1 in Domain.Config.Configuration.__schema__(:fields))) - - db_config = - if :db not in ignored_sources and one_of_keys_is_stored_in_db?, - do: get_account_config_by_account_id(account_id), - else: %{} - - # credo:disable-for-lines:4 - env_config = - if :env not in ignored_sources, - do: System.get_env(), - else: %{} - - {db_config, env_config} - end - @doc """ Similar to `compile_config/2` but raises an error if the configuration is invalid. @@ -54,8 +32,8 @@ defmodule Domain.Config do If you need to resolve values from the database, use `fetch_config/1` or `fetch_config!/1`. """ - def compile_config!(module \\ Definitions, key, env_configurations \\ System.get_env()) do - case Fetcher.fetch_source_and_config(module, key, %{}, env_configurations) do + def compile_config!(module \\ Definitions, key, env_config \\ System.get_env()) do + case Fetcher.fetch_source_and_config(module, key, %{}, env_config) do {:ok, _source, value} -> value @@ -64,49 +42,6 @@ defmodule Domain.Config do end end - ## Configuration stored in database - - def get_account_config_by_account_id(account_id) do - queryable = Configuration.Query.by_account_id(account_id) - Repo.one(queryable) || %Configuration{account_id: account_id} - end - - def fetch_account_config(%Auth.Subject{} = subject) do - with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_permission()) do - {:ok, get_account_config_by_account_id(subject.account.id)} - end - end - - def change_account_config(%Configuration{} = configuration, attrs \\ %{}) do - Configuration.Changeset.changeset(configuration, attrs) - end - - def update_config(%Configuration{} = configuration, attrs, %Auth.Subject{} = subject) do - with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_permission()) do - case update_config(configuration, attrs) do - {:ok, configuration} -> - :ok = broadcast_update_to_account(configuration) - {:ok, configuration} - - {:error, changeset} -> - {:error, changeset} - end - end - end - - def update_config(%Configuration{} = configuration, attrs) do - Configuration.Changeset.changeset(configuration, attrs) - |> Repo.insert_or_update() - |> case do - {:ok, configuration} -> - :ok = broadcast_update_to_account(configuration) - {:ok, configuration} - - {:error, changeset} -> - {:error, changeset} - end - end - def config_changeset(changeset, schema_key, config_key \\ nil) do config_key = config_key || schema_key @@ -130,29 +65,13 @@ defmodule Domain.Config do ## Feature flag helpers - defp feature_enabled?(feature) do + def global_feature_enabled?(feature) do fetch_env!(:domain, :enabled_features) |> Keyword.fetch!(feature) end def sign_up_enabled? do - feature_enabled?(:sign_up) - end - - def flow_activities_enabled? do - feature_enabled?(:flow_activities) - end - - def multi_site_resources_enabled? do - feature_enabled?(:multi_site_resources) - end - - def traffic_filters_enabled? do - feature_enabled?(:traffic_filters) - end - - def self_hosted_relays_enabled? do - feature_enabled?(:self_hosted_relays) + global_feature_enabled?(:sign_up) end ## Test helpers @@ -209,29 +128,13 @@ defmodule Domain.Config do end def feature_flag_override(feature, value) do - enabled_features = fetch_env!(:domain, :enabled_features) |> Keyword.put(feature, value) + enabled_features = + fetch_env!(:domain, :enabled_features) + |> Keyword.put(feature, value) + put_env_override(:enabled_features, enabled_features) end defp pdict_key_function(app, key), do: {app, key} end - - ### PubSub - - defp account_topic(%Domain.Accounts.Account{} = account), do: account_topic(account.id) - defp account_topic(account_id), do: "configs:#{account_id}" - - def subscribe_to_events_in_account(account_or_id) do - PubSub.subscribe(account_topic(account_or_id)) - end - - defp broadcast_update_to_account(configuration) do - broadcast_to_account(configuration.account_id, :config_changed) - end - - defp broadcast_to_account(account_or_id, payload) do - account_or_id - |> account_topic() - |> PubSub.broadcast(payload) - end end diff --git a/elixir/apps/domain/lib/domain/config/configuration.ex b/elixir/apps/domain/lib/domain/config/configuration.ex deleted file mode 100644 index b797848f2..000000000 --- a/elixir/apps/domain/lib/domain/config/configuration.ex +++ /dev/null @@ -1,14 +0,0 @@ -defmodule Domain.Config.Configuration do - use Domain, :schema - alias Domain.Config.Logo - alias Domain.Config.Configuration.ClientsUpstreamDNS - - schema "configurations" do - embeds_many :clients_upstream_dns, ClientsUpstreamDNS, on_replace: :delete - embeds_one :logo, Logo, on_replace: :delete - - belongs_to :account, Domain.Accounts.Account - - timestamps() - end -end diff --git a/elixir/apps/domain/lib/domain/config/configuration/changeset.ex b/elixir/apps/domain/lib/domain/config/configuration/changeset.ex deleted file mode 100644 index 5e1848920..000000000 --- a/elixir/apps/domain/lib/domain/config/configuration/changeset.ex +++ /dev/null @@ -1,61 +0,0 @@ -defmodule Domain.Config.Configuration.Changeset do - use Domain, :changeset - import Domain.Config, only: [config_changeset: 2] - alias Domain.Config.Configuration.ClientsUpstreamDNS - - @fields ~w[clients_upstream_dns logo]a - - def changeset(configuration, attrs) do - changeset = - configuration - |> cast(attrs, []) - |> cast_embed(:logo) - |> cast_embed(:clients_upstream_dns) - |> validate_unique_dns() - - Enum.reduce(@fields, changeset, fn field, changeset -> - config_changeset(changeset, field) - end) - |> ensure_no_overridden_changes(configuration.account_id) - end - - defp validate_unique_dns(changeset) do - dns_addrs = - apply_changes(changeset) - |> Map.get(:clients_upstream_dns) - |> Enum.map(&ClientsUpstreamDNS.normalize_dns_address/1) - |> Enum.reject(&is_nil/1) - - duplicates = dns_addrs -- Enum.uniq(dns_addrs) - - if duplicates != [] do - add_error(changeset, :clients_upstream_dns, "no duplicates allowed") - else - changeset - end - end - - defp ensure_no_overridden_changes(changeset, account_id) do - changed_keys = Map.keys(changeset.changes) - - configs = - Domain.Config.fetch_resolved_configs_with_sources!(account_id, changed_keys, - ignore_sources: :db - ) - - Enum.reduce(changed_keys, changeset, fn key, changeset -> - case Map.fetch!(configs, key) do - {{:env, source_key}, _value} -> - add_error( - changeset, - key, - "cannot be changed; " <> - "it is overridden by #{source_key} environment variable" - ) - - _other -> - changeset - end - end) - end -end diff --git a/elixir/apps/domain/lib/domain/config/configuration/clients_upstream_dns.ex b/elixir/apps/domain/lib/domain/config/configuration/clients_upstream_dns.ex deleted file mode 100644 index bd4e2bb65..000000000 --- a/elixir/apps/domain/lib/domain/config/configuration/clients_upstream_dns.ex +++ /dev/null @@ -1,63 +0,0 @@ -defmodule Domain.Config.Configuration.ClientsUpstreamDNS do - @moduledoc """ - Embedded Schema for Clients Upstream DNS - """ - use Domain, :schema - import Domain.Validator - import Ecto.Changeset - alias Domain.Types.IPPort - - @primary_key false - embedded_schema do - field :protocol, Ecto.Enum, values: [:ip_port, :dns_over_tls, :dns_over_http] - field :address, :string - end - - def changeset(dns_config \\ %__MODULE__{}, attrs) do - dns_config - |> cast(attrs, [:protocol, :address]) - |> validate_required([:protocol, :address]) - |> trim_change(:address) - |> validate_inclusion(:protocol, supported_protocols(), - message: "this type of DNS provider is not supported yet" - ) - |> validate_address() - end - - def supported_protocols do - ~w[ip_port]a - end - - def validate_address(changeset) do - if has_errors?(changeset, :protocol) do - changeset - else - case fetch_field(changeset, :protocol) do - {_changes_or_data, :ip_port} -> validate_ip_port(changeset) - :error -> changeset - end - end - end - - def validate_ip_port(changeset) do - validate_change(changeset, :address, fn :address, address -> - case IPPort.cast(address) do - {:ok, _ip} -> [] - _ -> [address: "must be a valid IP address"] - end - end) - end - - def normalize_dns_address(%__MODULE__{protocol: :ip_port, address: address}) do - case IPPort.cast(address) do - {:ok, ip} -> IPPort.put_default_port(ip, default_dns_port()) |> to_string() - _ -> address - end - end - - def normalize_dns_address(%__MODULE__{protocol: _, address: address}) do - address - end - - def default_dns_port, do: 53 -end diff --git a/elixir/apps/domain/lib/domain/config/configuration/query.ex b/elixir/apps/domain/lib/domain/config/configuration/query.ex deleted file mode 100644 index 502f3bcd8..000000000 --- a/elixir/apps/domain/lib/domain/config/configuration/query.ex +++ /dev/null @@ -1,15 +0,0 @@ -defmodule Domain.Config.Configuration.Query do - use Domain, :query - - def all do - from(configurations in Domain.Config.Configuration, as: :configurations) - end - - def by_id(queryable \\ all(), id) do - where(queryable, [configurations: configurations], configurations.id == ^id) - end - - def by_account_id(queryable \\ all(), account_id) do - where(queryable, [configurations: configurations], configurations.account_id == ^account_id) - end -end diff --git a/elixir/apps/domain/lib/domain/config/definitions.ex b/elixir/apps/domain/lib/domain/config/definitions.ex index ba97eea3f..93d1bdd20 100644 --- a/elixir/apps/domain/lib/domain/config/definitions.ex +++ b/elixir/apps/domain/lib/domain/config/definitions.ex @@ -30,7 +30,6 @@ defmodule Domain.Config.Definitions do use Domain.Config.Definition alias Domain.Config.Dumper alias Domain.Types - alias Domain.Config.Logo if Mix.env() in [:test, :dev] do @local_development_adapters [Swoosh.Adapters.Local] @@ -87,10 +86,6 @@ defmodule Domain.Config.Definitions do :cookie_signing_salt, :cookie_encryption_salt ]}, - {"Clients", - [ - :clients_upstream_dns - ]}, {"Authorization", """ Providers: @@ -409,29 +404,6 @@ defmodule Domain.Config.Definitions do changeset: &Domain.Validator.validate_base64/2 ) - ############################################## - ## Clients - ############################################## - - @doc """ - Comma-separated list of upstream DNS servers to use for clients. - - It can be one of the following: - - IP address - - FQDN if you intend to use a DNS-over-TLS server - - URI if you intent to use a DNS-over-HTTPS server - - Leave this blank to omit the `DNS` section from generated configs, - which will make clients use default system-provided DNS even when VPN session is active. - """ - defconfig( - :clients_upstream_dns, - {:json_array, {:embed, Domain.Config.Configuration.ClientsUpstreamDNS}, - validate_unique: false}, - default: [], - changeset: {Domain.Config.Configuration.ClientsUpstreamDNS, :changeset, []} - ) - ############################################## ## Userpass / SAML / OIDC / Email authentication ############################################## @@ -572,16 +544,13 @@ defmodule Domain.Config.Definitions do ) ############################################## - ## Appearance + ## Billing flags ############################################## - @doc """ - The path to a logo image file to replace default Firezone logo. - """ - defconfig(:logo, {:embed, Logo}, - default: nil, - changeset: {Logo, :changeset, []} - ) + defconfig(:billing_enabled, :boolean, default: false) + defconfig(:stripe_secret_key, :string, sensitive: true, default: nil) + defconfig(:stripe_webhook_signing_secret, :string, sensitive: true, default: nil) + defconfig(:stripe_default_price_id, :string, default: nil) ############################################## ## Local development and Staging Helpers @@ -592,30 +561,39 @@ defmodule Domain.Config.Definitions do ############################################## ## Feature Flags + ## + ## If feature is disabled globally it won't be available for any account, + ## even if account-specific override enables them. + ## ############################################## @doc """ - Boolean flag to turn Sign-ups on/off. + Boolean flag to turn Sign-ups on/off for all accounts. """ defconfig(:feature_sign_up_enabled, :boolean, default: true) @doc """ - Boolean flag to turn UI flow activities on/off. + Boolean flag to turn IdP sync on/off for all accounts. + """ + defconfig(:feature_idp_sync_enabled, :boolean, default: true) + + @doc """ + Boolean flag to turn UI flow activities on/off for all accounts. """ defconfig(:feature_flow_activities_enabled, :boolean, default: false) @doc """ - Boolean flag to turn Resource traffic filters on/off. + Boolean flag to turn Resource traffic filters on/off for all accounts. """ defconfig(:feature_traffic_filters_enabled, :boolean, default: false) @doc """ - Boolean flag to turn Account relays admin functionality on/off. + Boolean flag to turn Account relays admin functionality on/off for all accounts. """ defconfig(:feature_self_hosted_relays_enabled, :boolean, default: false) @doc """ - Boolean flag to turn Multi-Site resources functionality on/off. + Boolean flag to turn Multi-Site resources functionality on/off for all accounts. """ defconfig(:feature_multi_site_resources_enabled, :boolean, default: false) end diff --git a/elixir/apps/domain/lib/domain/config/errors.ex b/elixir/apps/domain/lib/domain/config/errors.ex index cf04a8950..6f47051ac 100644 --- a/elixir/apps/domain/lib/domain/config/errors.ex +++ b/elixir/apps/domain/lib/domain/config/errors.ex @@ -112,7 +112,7 @@ defmodule Domain.Config.Errors do end defp db_example(key) do - if key in Domain.Config.Configuration.__schema__(:fields) do + if key in Domain.Accounts.Config.__schema__(:fields) do """ ### Using database diff --git a/elixir/apps/domain/lib/domain/config/logo.ex b/elixir/apps/domain/lib/domain/config/logo.ex deleted file mode 100644 index 633385f50..000000000 --- a/elixir/apps/domain/lib/domain/config/logo.ex +++ /dev/null @@ -1,48 +0,0 @@ -defmodule Domain.Config.Logo do - @moduledoc """ - Embedded Schema for logo - """ - use Domain, :schema - import Domain.Validator - import Ecto.Changeset - - @whitelisted_file_extensions ~w[.jpg .jpeg .png .gif .webp .avif .svg .tiff] - - # Singleton per configuration - @primary_key false - embedded_schema do - field :url, :string - field :file, :string - field :data, :string - field :type, :string - end - - def __types__, do: ~w[Default File URL Upload] - - def type(nil), do: "Default" - def type(%{file: path}) when not is_nil(path), do: "File" - def type(%{url: url}) when not is_nil(url), do: "URL" - def type(%{data: data}) when not is_nil(data), do: "Upload" - - def changeset(logo \\ %__MODULE__{}, attrs) do - logo - |> cast(attrs, [:url, :data, :file, :type]) - |> validate_file(:file, extensions: @whitelisted_file_extensions) - |> move_file_to_static - end - - defp move_file_to_static(changeset) do - case fetch_change(changeset, :file) do - {:ok, file} -> - directory = Path.join(Application.app_dir(:domain), "priv/static/uploads/logo") - file_name = Path.basename(file) - file_path = Path.join(directory, file_name) - File.mkdir_p!(directory) - File.cp!(file, file_path) - put_change(changeset, :file, file_name) - - :error -> - changeset - end - end -end diff --git a/elixir/apps/domain/lib/domain/gateways.ex b/elixir/apps/domain/lib/domain/gateways.ex index ff8c6c3f2..6d1396b3e 100644 --- a/elixir/apps/domain/lib/domain/gateways.ex +++ b/elixir/apps/domain/lib/domain/gateways.ex @@ -1,7 +1,7 @@ defmodule Domain.Gateways do use Supervisor alias Domain.{Repo, Auth, Validator, Geo, PubSub} - alias Domain.{Accounts, Resources, Tokens} + alias Domain.{Accounts, Resources, Tokens, Billing} alias Domain.Gateways.{Authorizer, Gateway, Group, Presence} def start_link(opts) do @@ -16,6 +16,11 @@ defmodule Domain.Gateways do Supervisor.init(children, strategy: :one_for_one) end + def count_groups_for_account(%Accounts.Account{} = account) do + Group.Query.by_account_id(account.id) + |> Repo.aggregate(:count) + end + def fetch_group_by_id(id) do with true <- Validator.valid_uuid?(id) do Group.Query.by_id(id) @@ -122,10 +127,14 @@ defmodule Domain.Gateways do end def create_group(attrs, %Auth.Subject{} = subject) do - with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_gateways_permission()) do + with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_gateways_permission()), + true <- Billing.can_create_gateway_groups?(subject.account) do subject.account |> Group.Changeset.create(attrs, subject) |> Repo.insert() + else + false -> {:error, :gateway_groups_limit_reached} + {:error, reason} -> {:error, reason} end end diff --git a/elixir/apps/domain/lib/domain/ops.ex b/elixir/apps/domain/lib/domain/ops.ex index 5921652d8..71454996d 100644 --- a/elixir/apps/domain/lib/domain/ops.ex +++ b/elixir/apps/domain/lib/domain/ops.ex @@ -1,12 +1,20 @@ defmodule Domain.Ops do - def provision_account(%{ - account_name: account_name, - account_slug: account_slug, - account_admin_name: account_admin_name, - account_admin_email: account_admin_email - }) do + def create_and_provision_account(opts) do + %{ + name: account_name, + slug: account_slug, + admin_name: account_admin_name, + admin_email: account_admin_email + } = Enum.into(opts, %{}) + Domain.Repo.transaction(fn -> - {:ok, account} = Domain.Accounts.create_account(%{name: account_name, slug: account_slug}) + {:ok, account} = + Domain.Accounts.create_account(%{ + name: account_name, + slug: account_slug + }) + + {:ok, account} = Domain.Billing.provision_account(account) {:ok, _everyone_group} = Domain.Actors.create_managed_group(account, %{ @@ -22,13 +30,18 @@ defmodule Domain.Ops do }) {:ok, actor} = - Domain.Actors.create_actor(account, %{type: :account_admin_user, name: account_admin_name}) + Domain.Actors.create_actor(account, %{ + type: :account_admin_user, + name: account_admin_name + }) - {:ok, _identity} = + {:ok, identity} = Domain.Auth.upsert_identity(actor, magic_link_provider, %{ provider_identifier: account_admin_email, provider_identifier_confirmation: account_admin_email }) + + %{account: account, provider: magic_link_provider, actor: actor, identity: identity} end) end @@ -41,11 +54,13 @@ defmodule Domain.Ops do {:ok, actor} = Domain.Actors.create_actor(account, %{type: :account_admin_user, name: "Firezone Support"}) - {:ok, _identity} = + {:ok, identity} = Domain.Auth.upsert_identity(actor, magic_link_provider, %{ provider_identifier: "ent-support@firezone.dev", provider_identifier_confirmation: "ent-support@firezone.dev" }) + + {actor, identity} end) end end diff --git a/elixir/apps/domain/lib/domain/tokens/token/changeset.ex b/elixir/apps/domain/lib/domain/tokens/token/changeset.ex index eecf84ae6..2f08af9b4 100644 --- a/elixir/apps/domain/lib/domain/tokens/token/changeset.ex +++ b/elixir/apps/domain/lib/domain/tokens/token/changeset.ex @@ -25,7 +25,7 @@ defmodule Domain.Tokens.Token.Changeset do %Token{} |> cast(attrs, @create_attrs) |> validate_required(@required_attrs) - |> validate_inclusion(:type, [:email, :browser, :client, :relay_group]) + |> validate_inclusion(:type, [:email, :browser, :client, :relay_group, :api_client]) |> changeset() |> put_change(:created_by, :system) end diff --git a/elixir/apps/domain/priv/repo/migrations/20240212204654_add_accounts_billing.exs b/elixir/apps/domain/priv/repo/migrations/20240212204654_add_accounts_billing.exs new file mode 100644 index 000000000..c449e322d --- /dev/null +++ b/elixir/apps/domain/priv/repo/migrations/20240212204654_add_accounts_billing.exs @@ -0,0 +1,19 @@ +defmodule Domain.Repo.Migrations.AddAccountsBilling do + use Ecto.Migration + + def change do + alter table(:accounts) do + add(:features, :map, default: %{}, null: false) + add(:limits, :map, default: %{}, null: false) + add(:config, :map, default: %{}, null: false) + add(:metadata, :map, default: %{}, null: false) + + add(:warning, :text) + add(:warning_delivery_attempts, :integer) + add(:warning_last_sent_at, :utc_datetime_usec) + + add(:disabled_reason, :text) + add(:disabled_at, :utc_datetime_usec) + end + end +end diff --git a/elixir/apps/domain/priv/repo/migrations/20240216144024_add_various_indexes.exs b/elixir/apps/domain/priv/repo/migrations/20240216144024_add_various_indexes.exs new file mode 100644 index 000000000..29059b310 --- /dev/null +++ b/elixir/apps/domain/priv/repo/migrations/20240216144024_add_various_indexes.exs @@ -0,0 +1,22 @@ +defmodule Domain.Repo.Migrations.AddVariousIndexes do + use Ecto.Migration + + def change do + execute(""" + CREATE INDEX clients_account_id_last_seen_at_index + ON clients (account_id, last_seen_at DESC) + WHERE deleted_at IS NULL + """) + + execute(""" + CREATE INDEX actors_account_id_index + ON clients (account_id) + WHERE deleted_at IS NULL + """) + + execute(""" + CREATE INDEX actor_group_memberships_account_id_group_id_actor_id_index + ON actor_group_memberships (account_id, group_id, actor_id) + """) + end +end diff --git a/elixir/apps/domain/priv/repo/seeds.exs b/elixir/apps/domain/priv/repo/seeds.exs index 619534e5f..5f6bc327f 100644 --- a/elixir/apps/domain/priv/repo/seeds.exs +++ b/elixir/apps/domain/priv/repo/seeds.exs @@ -21,7 +21,23 @@ end slug: "firezone" }) -account = maybe_repo_update.(account, id: "c89bcc8c-9392-4dae-a40d-888aef6d28e0") +account = + maybe_repo_update.(account, + id: "c89bcc8c-9392-4dae-a40d-888aef6d28e0", + metadata: %{ + stripe: %{ + customer_id: "cus_PZKIfcHB6SSBA4", + subscription_id: "sub_1OkGm2ADeNU9NGxvbrCCw6m3", + product_name: "Enterprise" + } + }, + limits: %{ + monthly_active_users_count: 10, + service_accounts_count: 10, + gateway_groups_count: 3, + account_admin_users_count: 5 + } + ) {:ok, other_account} = Accounts.create_account(%{ diff --git a/elixir/apps/domain/test/domain/accounts_test.exs b/elixir/apps/domain/test/domain/accounts_test.exs index 804b9d59c..191c861ca 100644 --- a/elixir/apps/domain/test/domain/accounts_test.exs +++ b/elixir/apps/domain/test/domain/accounts_test.exs @@ -38,7 +38,7 @@ defmodule Domain.AccountsTest do {:error, {:unauthorized, reason: :missing_permissions, - missing_permissions: [Accounts.Authorizer.view_accounts_permission()]}} + missing_permissions: [Accounts.Authorizer.manage_own_account_permission()]}} end end @@ -76,7 +76,7 @@ defmodule Domain.AccountsTest do {:error, {:unauthorized, reason: :missing_permissions, - missing_permissions: [Accounts.Authorizer.view_accounts_permission()]}} + missing_permissions: [Accounts.Authorizer.manage_own_account_permission()]}} end end @@ -114,6 +114,269 @@ defmodule Domain.AccountsTest do end end + describe "update_account/3" do + setup do + account = Fixtures.Accounts.create_account(config: %{}) + subject = Fixtures.Auth.create_subject(account: account) + %{account: account, subject: subject} + end + + test "returns error when changeset is invalid", %{account: account, subject: subject} do + attrs = %{ + name: String.duplicate("a", 65), + features: %{ + idp_sync: 1 + }, + limits: %{ + monthly_active_users_count: -1 + }, + config: %{ + clients_upstream_dns: [%{protocol: "ip_port", address: "!!!"}] + } + } + + assert {:error, changeset} = update_account(account, attrs, subject) + + assert errors_on(changeset) == %{ + name: ["should be at most 64 character(s)"], + config: %{ + clients_upstream_dns: [ + %{address: ["must be a valid IP address"]} + ] + } + } + end + + test "updates account and broadcasts a message", %{account: account, subject: subject} do + attrs = %{ + name: Ecto.UUID.generate(), + features: %{ + idp_sync: false + }, + limits: %{ + monthly_active_users_count: 999 + }, + metadata: %{ + stripe: %{ + customer_id: "cus_1234567890", + subscription_id: "sub_1234567890" + } + }, + config: %{ + clients_upstream_dns: [ + %{protocol: "ip_port", address: "1.1.1.1"}, + %{protocol: "ip_port", address: "8.8.8.8"} + ] + } + } + + :ok = subscribe_to_events_in_account(account) + + assert {:ok, account} = update_account(account, attrs, subject) + + assert account.name == attrs.name + + # doesn't update features, filters, metadata or settings + assert account.features.idp_sync + + assert account.limits.monthly_active_users_count != + attrs.limits.monthly_active_users_count + + assert is_nil(account.metadata.stripe.customer_id) + + assert account.config.clients_upstream_dns == [ + %Domain.Accounts.Config.ClientsUpstreamDNS{ + protocol: :ip_port, + address: "1.1.1.1" + }, + %Domain.Accounts.Config.ClientsUpstreamDNS{ + protocol: :ip_port, + address: "8.8.8.8" + } + ] + + assert_receive :config_changed + end + + test "returns an error when trying to update other account", %{ + subject: subject + } do + other_account = Fixtures.Accounts.create_account() + assert update_account(other_account, %{}, subject) == {:error, :not_found} + end + + test "returns error when subject can not manage account", %{ + account: account, + subject: subject + } do + subject = Fixtures.Auth.remove_permissions(subject) + + assert update_account(account, %{}, subject) == + {:error, + {:unauthorized, + reason: :missing_permissions, + missing_permissions: [Accounts.Authorizer.manage_own_account_permission()]}} + end + end + + describe "update_account/2" do + setup do + account = Fixtures.Accounts.create_account(config: %{}) + %{account: account} + end + + test "returns error when changeset is invalid", %{account: account} do + attrs = %{ + name: String.duplicate("a", 65), + features: %{ + idp_sync: 1 + }, + limits: %{ + monthly_active_users_count: -1 + }, + config: %{ + clients_upstream_dns: [%{protocol: "ip_port", address: "!!!"}] + } + } + + assert {:error, changeset} = update_account(account, attrs) + + assert errors_on(changeset) == %{ + name: ["should be at most 64 character(s)"], + features: %{ + idp_sync: ["is invalid"] + }, + limits: %{ + monthly_active_users_count: ["must be greater than or equal to 0"] + }, + config: %{ + clients_upstream_dns: [ + %{address: ["must be a valid IP address"]} + ] + } + } + end + + test "trims client upstream dns config address fields", %{account: account} do + attrs = %{ + config: %{ + clients_upstream_dns: [ + %{protocol: "ip_port", address: " 1.1.1.1"}, + %{protocol: "ip_port", address: "8.8.8.8 "} + ] + } + } + + assert {:ok, account} = update_account(account, attrs) + + assert account.config.clients_upstream_dns == [ + %Domain.Accounts.Config.ClientsUpstreamDNS{ + protocol: :ip_port, + address: "1.1.1.1" + }, + %Domain.Accounts.Config.ClientsUpstreamDNS{ + protocol: :ip_port, + address: "8.8.8.8" + } + ] + end + + test "returns error on duplicate upstream dns config addresses", %{account: account} do + attrs = %{ + config: %{ + clients_upstream_dns: [ + %{protocol: "ip_port", address: "1.1.1.1:53"}, + %{protocol: "ip_port", address: "1.1.1.1 "} + ] + } + } + + assert {:error, changeset} = update_account(account, attrs) + + assert errors_on(changeset) == %{ + config: %{ + clients_upstream_dns: ["all addresses must be unique"] + } + } + end + + test "updates account and broadcasts a message", %{account: account} do + attrs = %{ + name: Ecto.UUID.generate(), + features: %{ + idp_sync: true, + self_hosted_relays: false + }, + limits: %{ + monthly_active_users_count: 999 + }, + metadata: %{ + stripe: %{ + customer_id: "cus_1234567890", + subscription_id: "sub_1234567890" + } + }, + config: %{ + clients_upstream_dns: [ + %{protocol: "ip_port", address: "1.1.1.1"}, + %{protocol: "ip_port", address: "8.8.8.8"} + ] + } + } + + :ok = subscribe_to_events_in_account(account) + + assert {:ok, account} = update_account(account, attrs) + + assert account.name == attrs.name + + assert account.features.idp_sync == attrs.features.idp_sync + assert account.features.self_hosted_relays == attrs.features.self_hosted_relays + + assert account.limits.monthly_active_users_count == + attrs.limits.monthly_active_users_count + + assert account.metadata.stripe.customer_id == + attrs.metadata.stripe.customer_id + + assert account.metadata.stripe.subscription_id == + attrs.metadata.stripe.subscription_id + + assert account.config.clients_upstream_dns == [ + %Domain.Accounts.Config.ClientsUpstreamDNS{ + protocol: :ip_port, + address: "1.1.1.1" + }, + %Domain.Accounts.Config.ClientsUpstreamDNS{ + protocol: :ip_port, + address: "8.8.8.8" + } + ] + + assert_receive :config_changed + end + end + + for feature <- Accounts.Features.__schema__(:fields) do + describe "#{:"#{feature}_enabled?"}/1" do + test "returns true when feature is enabled for account" do + account = Fixtures.Accounts.create_account(features: %{unquote(feature) => true}) + assert unquote(:"#{feature}_enabled?")(account) == true + end + + test "returns false when feature is disabled for account" do + account = Fixtures.Accounts.create_account(features: %{unquote(feature) => false}) + assert unquote(:"#{feature}_enabled?")(account) == false + end + + test "returns false when feature is disabled globally" do + Domain.Config.feature_flag_override(unquote(feature), false) + account = Fixtures.Accounts.create_account(features: %{unquote(feature) => true}) + assert unquote(:"#{feature}_enabled?")(account) == false + end + end + end + describe "ensure_has_access_to/2" do test "returns :ok if subject has access to the account" do subject = Fixtures.Auth.create_subject() diff --git a/elixir/apps/domain/test/domain/actors_test.exs b/elixir/apps/domain/test/domain/actors_test.exs index 221ff0fb9..d913aef1c 100644 --- a/elixir/apps/domain/test/domain/actors_test.exs +++ b/elixir/apps/domain/test/domain/actors_test.exs @@ -1962,7 +1962,7 @@ defmodule Domain.ActorsTest do end end - describe "create_actor/4" do + describe "create_actor/2" do setup do account = Fixtures.Accounts.create_account() @@ -2020,7 +2020,7 @@ defmodule Domain.ActorsTest do end end - describe "create_actor/5" do + describe "create_actor/3" do setup do account = Fixtures.Accounts.create_account() actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account) @@ -2047,6 +2047,78 @@ defmodule Domain.ActorsTest do assert is_nil(actor.deleted_at) end + test "returns error when seats limit is exceeded (admins)", %{ + account: account, + subject: subject + } do + {:ok, account} = + Domain.Accounts.update_account(account, %{ + limits: %{ + monthly_active_users_count: 1 + } + }) + + Fixtures.Clients.create_client(actor: [type: :account_admin_user], account: account) + + attrs = Fixtures.Actors.actor_attrs() + + assert create_actor(account, attrs, subject) == {:error, :seats_limit_reached} + end + + test "returns error when admins limit is exceeded", %{ + account: account, + subject: subject + } do + {:ok, account} = + Domain.Accounts.update_account(account, %{ + limits: %{ + account_admin_users_count: 1 + } + }) + + Fixtures.Actors.create_actor(type: :account_admin_user, account: account) + + attrs = Fixtures.Actors.actor_attrs(type: :account_admin_user) + + assert create_actor(account, attrs, subject) == {:error, :seats_limit_reached} + end + + test "returns error when seats limit is exceeded (users)", %{ + account: account, + subject: subject + } do + {:ok, account} = + Domain.Accounts.update_account(account, %{ + limits: %{ + monthly_active_users_count: 1 + } + }) + + Fixtures.Clients.create_client(actor: [type: :account_user], account: account) + + attrs = Fixtures.Actors.actor_attrs() + + assert create_actor(account, attrs, subject) == {:error, :seats_limit_reached} + end + + test "returns error when service accounts limit is exceeded", %{ + account: account, + subject: subject + } do + {:ok, account} = + Domain.Accounts.update_account(account, %{ + limits: %{ + service_accounts_count: 1 + } + }) + + Fixtures.Actors.create_actor(type: :service_account, account: account) + + attrs = Fixtures.Actors.actor_attrs(type: :service_account) + + assert create_actor(account, attrs, subject) == {:error, :service_accounts_limit_reached} + end + test "returns error when subject can not create actors", %{ account: account, subject: subject diff --git a/elixir/apps/domain/test/domain/auth/adapters/google_workspace/jobs_test.exs b/elixir/apps/domain/test/domain/auth/adapters/google_workspace/jobs_test.exs index 5cf6f0614..7a65a9f03 100644 --- a/elixir/apps/domain/test/domain/auth/adapters/google_workspace/jobs_test.exs +++ b/elixir/apps/domain/test/domain/auth/adapters/google_workspace/jobs_test.exs @@ -95,6 +95,19 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace.JobsTest do } end + test "returns error when IdP sync is not enabled", %{account: account, provider: provider} do + {:ok, _account} = Domain.Accounts.update_account(account, %{features: %{idp_sync: false}}) + + assert sync_directory(%{}) == :ok + + assert updated_provider = Repo.get(Domain.Auth.Provider, provider.id) + refute updated_provider.last_synced_at + assert updated_provider.last_syncs_failed == 1 + + assert updated_provider.last_sync_error == + "IdP sync is not enabled in your subscription plan" + end + test "syncs IdP data", %{provider: provider} do bypass = Bypass.open() diff --git a/elixir/apps/domain/test/domain/auth/adapters/microsoft_entra/jobs_test.exs b/elixir/apps/domain/test/domain/auth/adapters/microsoft_entra/jobs_test.exs index 795d63631..352a5bbdd 100644 --- a/elixir/apps/domain/test/domain/auth/adapters/microsoft_entra/jobs_test.exs +++ b/elixir/apps/domain/test/domain/auth/adapters/microsoft_entra/jobs_test.exs @@ -95,6 +95,19 @@ defmodule Domain.Auth.Adapters.MicrosoftEntra.JobsTest do } end + test "returns error when IdP sync is not enabled", %{account: account, provider: provider} do + {:ok, _account} = Domain.Accounts.update_account(account, %{features: %{idp_sync: false}}) + + assert sync_directory(%{}) == :ok + + assert updated_provider = Repo.get(Domain.Auth.Provider, provider.id) + refute updated_provider.last_synced_at + assert updated_provider.last_syncs_failed == 1 + + assert updated_provider.last_sync_error == + "IdP sync is not enabled in your subscription plan" + end + test "syncs IdP data", %{provider: provider} do bypass = Bypass.open() diff --git a/elixir/apps/domain/test/domain/auth_test.exs b/elixir/apps/domain/test/domain/auth_test.exs index 4abd73e28..fb8f3afed 100644 --- a/elixir/apps/domain/test/domain/auth_test.exs +++ b/elixir/apps/domain/test/domain/auth_test.exs @@ -6,16 +6,25 @@ defmodule Domain.AuthTest do # Providers - describe "list_provider_adapters/0" do + describe "list_user_provisioned_provider_adapters!/1" do test "returns list of enabled adapters for an account" do - assert {:ok, adapters} = list_provider_adapters() + account = Fixtures.Accounts.create_account(features: %{idp_sync: true}) - assert adapters == %{ - openid_connect: Domain.Auth.Adapters.OpenIDConnect, - google_workspace: Domain.Auth.Adapters.GoogleWorkspace, - microsoft_entra: Domain.Auth.Adapters.MicrosoftEntra, - okta: Domain.Auth.Adapters.Okta - } + assert list_user_provisioned_provider_adapters!(account) == [ + openid_connect: [enabled: true], + google_workspace: [enabled: true], + microsoft_entra: [enabled: true], + okta: [enabled: true] + ] + + account = Fixtures.Accounts.create_account(features: %{idp_sync: false}) + + assert list_user_provisioned_provider_adapters!(account) == [ + openid_connect: [enabled: true], + google_workspace: [enabled: false], + microsoft_entra: [enabled: false], + okta: [enabled: false] + ] end end @@ -601,6 +610,23 @@ defmodule Domain.AuthTest do assert errors_on(changeset) == %{base: ["this provider is already connected"]} end + test "returns error if provider is disabled by account feature flag", %{ + account: account + } do + {:ok, account} = Domain.Accounts.update_account(account, %{features: %{idp_sync: false}}) + + attrs = + Fixtures.Auth.provider_attrs( + adapter: :google_workspace, + adapter_config: %{client_id: "foo", client_secret: "bar"}, + provisioner: :custom + ) + + assert {:error, changeset} = create_provider(account, attrs) + refute changeset.valid? + assert errors_on(changeset) == %{adapter: ["is invalid"]} + end + test "creates a provider", %{ account: account } do diff --git a/elixir/apps/domain/test/domain/billing/jobs_test.exs b/elixir/apps/domain/test/domain/billing/jobs_test.exs new file mode 100644 index 000000000..505e58213 --- /dev/null +++ b/elixir/apps/domain/test/domain/billing/jobs_test.exs @@ -0,0 +1,71 @@ +defmodule Domain.Billing.JobsTest do + use Domain.DataCase, async: true + import Domain.Billing.Jobs + + describe "check_account_limits/1" do + setup do + account = + Fixtures.Accounts.create_account( + metadata: %{ + stripe: %{ + customer_id: "cus_123", + subscription_id: "sub_123" + } + } + ) + + %{ + account: account + } + end + + test "does nothing when limits are not violated", %{ + account: account + } do + assert check_account_limits(%{}) == :ok + + account = Repo.get!(Domain.Accounts.Account, account.id) + refute account.warning + assert account.warning_delivery_attempts == 0 + refute account.warning_last_sent_at + end + + test "puts a warning for an account when limits are violated", %{ + account: account + } do + Fixtures.Clients.create_client(account: account, actor: [type: :account_user]) + Fixtures.Clients.create_client(account: account, actor: [type: :account_user]) + + Fixtures.Actors.create_actor(type: :account_admin_user, account: account) + Fixtures.Actors.create_actor(type: :account_admin_user, account: account) + + Fixtures.Actors.create_actor(type: :service_account, account: account) + Fixtures.Actors.create_actor(type: :service_account, account: account) + + Fixtures.Gateways.create_group(account: account) + Fixtures.Gateways.create_group(account: account) + + Domain.Accounts.update_account(account, %{ + limits: %{ + monthly_active_users_count: 1, + service_accounts_count: 1, + gateway_groups_count: 1, + account_admin_users_count: 1 + } + }) + + assert check_account_limits(%{}) == :ok + + account = Repo.get!(Domain.Accounts.Account, account.id) + + assert account.warning =~ "You have exceeded the following limits:" + assert account.warning =~ "monthly active users" + assert account.warning =~ "service accounts" + assert account.warning =~ "sites" + assert account.warning =~ "account admins" + + assert account.warning_delivery_attempts == 0 + assert account.warning_last_sent_at + end + end +end diff --git a/elixir/apps/domain/test/domain/billing_test.exs b/elixir/apps/domain/test/domain/billing_test.exs new file mode 100644 index 000000000..0328e9220 --- /dev/null +++ b/elixir/apps/domain/test/domain/billing_test.exs @@ -0,0 +1,231 @@ +defmodule Domain.BillingTest do + use Domain.DataCase, async: true + import Domain.Billing + alias Domain.Billing + alias Domain.Mocks.Stripe + + setup do + account = Fixtures.Accounts.create_account() + actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account) + identity = Fixtures.Auth.create_identity(account: account, actor: actor) + subject = Fixtures.Auth.create_subject(identity: identity) + + %{ + account: account, + actor: actor, + identity: identity, + subject: subject + } + end + + describe "provision_account/1" do + test "returns account if billing is disabled", %{account: account} do + Domain.Config.put_env_override(Domain.Billing, + secret_key: nil, + default_price_id: nil, + enabled: false + ) + + assert provision_account(account) == {:ok, account} + end + + test "creates a customer and persists it's ID in the account", %{account: account} do + Bypass.open() + |> Stripe.mock_create_customer_endpoint(account) + |> Stripe.mock_create_subscription_endpoint() + + assert {:ok, account} = provision_account(account) + assert account.metadata.stripe.customer_id == "cus_NffrFeUfNV2Hib" + assert account.metadata.stripe.subscription_id == "sub_1MowQVLkdIwHu7ixeRlqHVzs" + + assert_receive {:bypass_request, %{request_path: "/v1/customers"} = conn} + assert conn.params == %{"name" => account.name, "metadata" => %{"account_id" => account.id}} + end + + test "returns error when Stripe API call fails", %{account: account} do + bypass = Bypass.open() + Stripe.override_endpoint_url("http://localhost:#{bypass.port}") + Bypass.down(bypass) + + assert provision_account(account) == {:error, :retry_later} + end + end + + describe "billing_portal_url/3" do + test "returns valid billing portal url", %{account: account, subject: subject} do + bypass = + Bypass.open() + |> Stripe.mock_create_customer_endpoint(account) + |> Stripe.mock_create_subscription_endpoint() + + assert {:ok, account} = provision_account(account) + + Stripe.mock_create_billing_session_endpoint(bypass, account) + assert {:ok, url} = billing_portal_url(account, "https://example.com/account", subject) + assert url =~ "billing.stripe.com" + + assert_receive {:bypass_request, %{request_path: "/v1/billing_portal/sessions"} = conn} + + assert conn.params == %{ + "customer" => account.metadata.stripe.customer_id, + "return_url" => "https://example.com/account" + } + end + + test "returns error when subject has no permission to manage account billing", %{ + account: account, + subject: subject + } do + subject = Fixtures.Auth.remove_permissions(subject) + + assert billing_portal_url(account, "https://example.com/account", subject) == + {:error, + {:unauthorized, + reason: :missing_permissions, + missing_permissions: [Billing.Authorizer.manage_own_account_billing_permission()]}} + end + end + + describe "handle_events/1" do + setup %{account: account} do + customer_id = "cus_" <> Ecto.UUID.generate() + + {:ok, account} = + Domain.Accounts.update_account(account, %{ + metadata: %{stripe: %{customer_id: customer_id}}, + features: %{ + flow_activities: nil, + multi_site_resources: nil, + traffic_filters: nil, + self_hosted_relays: nil, + idp_sync: nil + }, + limits: %{ + monthly_active_users_count: nil + } + }) + + %{account: account, customer_id: customer_id} + end + + test "disables the account on when subscription is deleted", %{ + account: account, + customer_id: customer_id + } do + Bypass.open() |> Stripe.mock_fetch_customer_endpoint(account) + + event = + Stripe.build_event( + "customer.subscription.deleted", + Stripe.subscription_object(customer_id, %{}, %{}, 0) + ) + + assert handle_events([event]) == :ok + + assert account = Repo.get(Domain.Accounts.Account, account.id) + assert not is_nil(account.disabled_at) + assert account.disabled_reason == "Stripe subscription deleted" + end + + test "disables the account on when subscription is paused (updated event)", %{ + account: account, + customer_id: customer_id + } do + Bypass.open() |> Stripe.mock_fetch_customer_endpoint(account) + + event = + Stripe.build_event( + "customer.subscription.updated", + Stripe.subscription_object(customer_id, %{}, %{}, 0) + |> Map.put("pause_collection", %{"behavior" => "void"}) + ) + + assert handle_events([event]) == :ok + + assert account = Repo.get(Domain.Accounts.Account, account.id) + assert not is_nil(account.disabled_at) + assert account.disabled_reason == "Stripe subscription paused" + end + + test "re-enables the account on subscription update", %{ + account: account, + customer_id: customer_id + } do + Bypass.open() + |> Stripe.mock_fetch_customer_endpoint(account) + |> Stripe.mock_fetch_product_endpoint("prod_Na6dGcTsmU0I4R") + + {:ok, account} = + Domain.Accounts.update_account(account, %{ + disabled_at: DateTime.utc_now(), + disabled_reason: "Stripe subscription paused" + }) + + event = + Stripe.build_event( + "customer.subscription.updated", + Stripe.subscription_object(customer_id, %{}, %{}, 0) + ) + + assert handle_events([event]) == :ok + + assert account = Repo.get(Domain.Accounts.Account, account.id) + assert account.disabled_at == nil + assert account.disabled_reason == nil + end + + test "updates account features and limits on subscription update", %{ + account: account, + customer_id: customer_id + } do + Bypass.open() + |> Stripe.mock_fetch_customer_endpoint(account) + |> Stripe.mock_fetch_product_endpoint("prod_Na6dGcTsmU0I4R", %{ + metadata: %{ + "multi_site_resources" => "false", + "self_hosted_relays" => "true", + "monthly_active_users_count" => "15", + "service_accounts_count" => "unlimited", + "gateway_groups_count" => 1 + } + }) + + subscription_metadata = %{ + "idp_sync" => "true", + "multi_site_resources" => "true", + "traffic_filters" => "false", + "gateway_groups_count" => 5 + } + + quantity = 13 + + event = + Stripe.build_event( + "customer.subscription.updated", + Stripe.subscription_object(customer_id, subscription_metadata, %{}, quantity) + ) + + assert handle_events([event]) == :ok + + assert account = Repo.get(Domain.Accounts.Account, account.id) + + assert account.metadata.stripe.customer_id == customer_id + assert account.metadata.stripe.subscription_id + assert account.metadata.stripe.product_name == "Enterprise" + + assert account.limits == %Domain.Accounts.Limits{ + monthly_active_users_count: 15, + gateway_groups_count: 5, + service_accounts_count: nil + } + + assert account.features == %Domain.Accounts.Features{ + flow_activities: nil, + idp_sync: true, + multi_site_resources: true, + self_hosted_relays: true, + traffic_filters: false + } + end + end +end diff --git a/elixir/apps/domain/test/domain/clients_test.exs b/elixir/apps/domain/test/domain/clients_test.exs index 14e068b9b..cb34f7360 100644 --- a/elixir/apps/domain/test/domain/clients_test.exs +++ b/elixir/apps/domain/test/domain/clients_test.exs @@ -39,6 +39,46 @@ defmodule Domain.ClientsTest do end end + describe "count_1m_active_users_for_account/1" do + test "returns 0 when there are no clients", %{account: account} do + assert count_1m_active_users_for_account(account) == 0 + end + + test "returns 0 when there are no clients active within one month", %{account: account} do + forty_days_ago = DateTime.utc_now() |> DateTime.add(-40, :day) + client = Fixtures.Clients.create_client(account: account) + client |> Ecto.Changeset.change(last_seen_at: forty_days_ago) |> Repo.update!() + assert count_1m_active_users_for_account(account) == 0 + end + + test "filters inactive actors", %{account: account} do + actor = Fixtures.Actors.create_actor(account: account) + Fixtures.Clients.create_client(account: account, actor: actor) + + Fixtures.Actors.disable(actor) + + assert count_1m_active_users_for_account(account) == 0 + end + + test "filters non-user actors", %{account: account} do + actor = Fixtures.Actors.create_actor(account: account, type: :service_account) + Fixtures.Clients.create_client(account: account, actor: actor) + assert count_1m_active_users_for_account(account) == 0 + end + + test "counts distinct actor ids in an account", %{account: account} do + actor1 = Fixtures.Actors.create_actor(account: account) + actor2 = Fixtures.Actors.create_actor(account: account) + + Fixtures.Clients.create_client(account: account, actor: actor1) + Fixtures.Clients.create_client(account: account, actor: actor1) + Fixtures.Clients.create_client(account: account, actor: actor2) + Fixtures.Clients.create_client() + + assert count_1m_active_users_for_account(account) == 2 + end + end + describe "count_by_actor_id/1" do test "returns 0 if actor does not exist" do assert count_by_actor_id(Ecto.UUID.generate()) == 0 diff --git a/elixir/apps/domain/test/domain/config/definition_test.exs b/elixir/apps/domain/test/domain/config/definition_test.exs index 797486d59..0aeec8b7a 100644 --- a/elixir/apps/domain/test/domain/config/definition_test.exs +++ b/elixir/apps/domain/test/domain/config/definition_test.exs @@ -57,11 +57,9 @@ defmodule Domain.Config.DefinitionTest do end test "inserts a function which returns definition doc" do - assert {:ok, doc} = fetch_doc(Domain.Config.Definitions, :clients_upstream_dns) - assert doc =~ "Comma-separated list of upstream DNS servers to use for clients." - - assert fetch_doc(Foo, :bar) == - {:error, :module_not_found} + assert {:ok, doc} = fetch_doc(Domain.Config.Definitions, :outbound_email_adapter) + assert doc =~ "Method to use for sending outbound email." + assert fetch_doc(Foo, :bar) == {:error, :module_not_found} end end diff --git a/elixir/apps/domain/test/domain/config/resolver_test.exs b/elixir/apps/domain/test/domain/config/resolver_test.exs index 85f115e6b..a83047eb8 100644 --- a/elixir/apps/domain/test/domain/config/resolver_test.exs +++ b/elixir/apps/domain/test/domain/config/resolver_test.exs @@ -37,7 +37,7 @@ defmodule Domain.Config.ResolverTest do test "returns variable from database" do env_configurations = %{} - db_configurations = %Domain.Config.Configuration{clients_upstream_dns: "1.2.3.4"} + db_configurations = %Domain.Accounts.Config{clients_upstream_dns: "1.2.3.4"} assert resolve(:clients_upstream_dns, env_configurations, db_configurations, []) == {:ok, {{:db, :clients_upstream_dns}, "1.2.3.4"}} diff --git a/elixir/apps/domain/test/domain/config_test.exs b/elixir/apps/domain/test/domain/config_test.exs index 36d40dd7f..cddf490fc 100644 --- a/elixir/apps/domain/test/domain/config_test.exs +++ b/elixir/apps/domain/test/domain/config_test.exs @@ -1,7 +1,6 @@ defmodule Domain.ConfigTest do use Domain.DataCase, async: true import Domain.Config - alias Domain.Config defmodule Test do use Domain.Config.Definition @@ -84,28 +83,15 @@ defmodule Domain.ConfigTest do describe "fetch_resolved_configs!/1" do setup do account = Fixtures.Accounts.create_account() - Fixtures.Config.upsert_configuration(account: account) %{account: account} end test "returns source and config values", %{account: account} do - assert fetch_resolved_configs!(account.id, [:clients_upstream_dns, :clients_upstream_dns]) == + assert fetch_resolved_configs!(account.id, [:outbound_email_adapter, :docker_registry]) == %{ - clients_upstream_dns: [ - %Domain.Config.Configuration.ClientsUpstreamDNS{ - protocol: :ip_port, - address: "1.1.1.1" - }, - %Domain.Config.Configuration.ClientsUpstreamDNS{ - protocol: :ip_port, - address: "2606:4700:4700::1111" - }, - %Domain.Config.Configuration.ClientsUpstreamDNS{ - protocol: :ip_port, - address: "8.8.8.8:853" - } - ] + outbound_email_adapter: nil, + docker_registry: "ghcr.io/firezone" } end @@ -144,30 +130,22 @@ defmodule Domain.ConfigTest do describe "fetch_resolved_configs_with_sources!/1" do setup do account = Fixtures.Accounts.create_account() - Fixtures.Config.upsert_configuration(account: account) %{account: account} end test "returns source and config values", %{account: account} do - assert fetch_resolved_configs_with_sources!(account.id, [:clients_upstream_dns]) == + %{ + docker_registry: "ghcr.io/firezone" + } + + assert fetch_resolved_configs_with_sources!(account.id, [ + :outbound_email_adapter, + :docker_registry + ]) == %{ - clients_upstream_dns: - {{:db, :clients_upstream_dns}, - [ - %Domain.Config.Configuration.ClientsUpstreamDNS{ - protocol: :ip_port, - address: "1.1.1.1" - }, - %Domain.Config.Configuration.ClientsUpstreamDNS{ - protocol: :ip_port, - address: "2606:4700:4700::1111" - }, - %Domain.Config.Configuration.ClientsUpstreamDNS{ - protocol: :ip_port, - address: "8.8.8.8:853" - } - ]} + outbound_email_adapter: {:default, nil}, + docker_registry: {:default, "ghcr.io/firezone"} } end @@ -357,228 +335,4 @@ defmodule Domain.ConfigTest do Domain.ConfigTest.Test end end - - describe "get_account_config_by_account_id/1" do - setup do - account = Fixtures.Accounts.create_account() - %{account: account} - end - - test "returns configuration for an account if it exists", %{ - account: account - } do - configuration = Fixtures.Config.upsert_configuration(account: account) - assert get_account_config_by_account_id(account.id) == configuration - end - - test "returns default configuration for an account if it does not exist", %{ - account: account - } do - assert get_account_config_by_account_id(account.id) == %Domain.Config.Configuration{ - account_id: account.id, - clients_upstream_dns: [] - } - end - end - - describe "fetch_account_config/1" do - setup do - account = Fixtures.Accounts.create_account() - - actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account) - identity = Fixtures.Auth.create_identity(account: account, actor: actor) - subject = Fixtures.Auth.create_subject(identity: identity) - - %{ - account: account, - actor: actor, - identity: identity, - subject: subject - } - end - - test "returns configuration for an account if it exists", %{ - account: account, - subject: subject - } do - configuration = Fixtures.Config.upsert_configuration(account: account) - assert fetch_account_config(subject) == {:ok, configuration} - end - - test "returns default configuration for an account if it does not exist", %{ - account: account, - subject: subject - } do - assert {:ok, config} = fetch_account_config(subject) - - assert config == %Domain.Config.Configuration{ - account_id: account.id, - clients_upstream_dns: [] - } - end - - test "returns error when subject does not have permission to read configuration", %{ - subject: subject - } do - subject = Fixtures.Auth.remove_permissions(subject) - - assert fetch_account_config(subject) == - {:error, - {:unauthorized, - reason: :missing_permissions, - missing_permissions: [Config.Authorizer.manage_permission()]}} - end - end - - describe "change_account_config/2" do - setup do - account = Fixtures.Accounts.create_account() - configuration = Fixtures.Config.upsert_configuration(account: account) - - %{account: account, configuration: configuration} - end - - test "returns config changeset", %{configuration: configuration} do - assert %Ecto.Changeset{} = change_account_config(configuration) - end - end - - describe "update_config/3" do - test "returns error when subject can not manage account configuration" do - account = Fixtures.Accounts.create_account() - config = get_account_config_by_account_id(account.id) - actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account) - identity = Fixtures.Auth.create_identity(account: account, actor: actor) - - subject = - Fixtures.Auth.create_subject(identity: identity) - |> Fixtures.Auth.remove_permissions() - - assert update_config(config, %{}, subject) == - {:error, - {:unauthorized, - reason: :missing_permissions, - missing_permissions: [Config.Authorizer.manage_permission()]}} - end - end - - describe "update_config/2" do - setup do - account = Fixtures.Accounts.create_account() - %{account: account} - end - - test "returns error when changeset is invalid", %{account: account} do - config = get_account_config_by_account_id(account.id) - - attrs = %{ - clients_upstream_dns: [%{protocol: "ip_port", address: "!!!"}] - } - - assert {:error, changeset} = update_config(config, attrs) - - assert errors_on(changeset) == %{ - clients_upstream_dns: [ - %{address: ["must be a valid IP address"]} - ] - } - end - - test "returns error when trying to change overridden value", %{account: account} do - put_system_env_override(:clients_upstream_dns, [%{protocol: "ip_port", address: "1.2.3.4"}]) - - config = get_account_config_by_account_id(account.id) - - attrs = %{ - clients_upstream_dns: [%{protocol: "ip_port", address: "4.1.2.3"}] - } - - assert {:error, changeset} = update_config(config, attrs) - - assert errors_on(changeset) == - %{ - clients_upstream_dns: [ - "cannot be changed; it is overridden by CLIENTS_UPSTREAM_DNS environment variable" - ] - } - end - - test "trims binary fields", %{account: account} do - config = get_account_config_by_account_id(account.id) - - attrs = %{ - clients_upstream_dns: [ - %{protocol: "ip_port", address: " 1.1.1.1"}, - %{protocol: "ip_port", address: "8.8.8.8 "} - ] - } - - assert {:ok, config} = update_config(config, attrs) - - assert config.clients_upstream_dns == [ - %Domain.Config.Configuration.ClientsUpstreamDNS{ - protocol: :ip_port, - address: "1.1.1.1" - }, - %Domain.Config.Configuration.ClientsUpstreamDNS{ - protocol: :ip_port, - address: "8.8.8.8" - } - ] - end - - test "changes database config value when it did not exist", %{account: account} do - config = get_account_config_by_account_id(account.id) - - attrs = %{ - clients_upstream_dns: [ - %{protocol: "ip_port", address: "1.1.1.1"}, - %{protocol: "ip_port", address: "8.8.8.8"} - ] - } - - :ok = subscribe_to_events_in_account(account) - - assert {:ok, config} = update_config(config, attrs) - - assert config.clients_upstream_dns == [ - %Domain.Config.Configuration.ClientsUpstreamDNS{ - protocol: :ip_port, - address: "1.1.1.1" - }, - %Domain.Config.Configuration.ClientsUpstreamDNS{ - protocol: :ip_port, - address: "8.8.8.8" - } - ] - - assert_receive :config_changed - end - - test "changes database config value when it existed", %{account: account} do - Fixtures.Config.upsert_configuration(account: account) - - config = get_account_config_by_account_id(account.id) - - attrs = %{ - clients_upstream_dns: [ - %{protocol: "ip_port", address: "8.8.8.8"}, - %{protocol: "ip_port", address: "8.8.4.4"} - ] - } - - assert {:ok, config} = update_config(config, attrs) - - assert config.clients_upstream_dns == [ - %Domain.Config.Configuration.ClientsUpstreamDNS{ - protocol: :ip_port, - address: "8.8.8.8" - }, - %Domain.Config.Configuration.ClientsUpstreamDNS{ - protocol: :ip_port, - address: "8.8.4.4" - } - ] - end - end end diff --git a/elixir/apps/domain/test/domain/gateways_test.exs b/elixir/apps/domain/test/domain/gateways_test.exs index 128d59845..99bbc7423 100644 --- a/elixir/apps/domain/test/domain/gateways_test.exs +++ b/elixir/apps/domain/test/domain/gateways_test.exs @@ -7,7 +7,7 @@ defmodule Domain.GatewaysTest do account = Fixtures.Accounts.create_account() actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account) identity = Fixtures.Auth.create_identity(account: account, actor: actor) - subject = Fixtures.Auth.create_subject(identity: identity) + subject = Fixtures.Auth.create_subject(account: account, actor: actor, identity: identity) %{ account: account, @@ -190,6 +190,26 @@ defmodule Domain.GatewaysTest do } end + test "returns error when billing limit is reached", %{account: account, subject: subject} do + {:ok, account} = + Domain.Accounts.update_account(account, %{ + limits: %{ + gateway_groups_count: 1 + } + }) + + subject = %{subject | account: account} + + Fixtures.Gateways.create_group(account: account) + + attrs = %{ + name: "foo", + routing: "managed" + } + + assert create_group(attrs, subject) == {:error, :gateway_groups_limit_reached} + end + test "creates a group", %{subject: subject} do attrs = %{ name: "foo", diff --git a/elixir/apps/domain/test/domain/ops_test.exs b/elixir/apps/domain/test/domain/ops_test.exs index a13ac0457..ecfe7156c 100644 --- a/elixir/apps/domain/test/domain/ops_test.exs +++ b/elixir/apps/domain/test/domain/ops_test.exs @@ -8,45 +8,76 @@ defmodule Domain.OpsTest do end test "provisions support account by slug" do - params = %{ - account_name: "Test Account", - account_slug: "test_account", - account_admin_name: "Test Admin", - account_admin_email: "test_admin@firezone.local" - } + account = Fixtures.Accounts.create_account() + Fixtures.Auth.create_email_provider(account: account) + assert {:ok, {actor, identity}} = provision_support_by_account_slug(account.slug) - assert {:ok, _} = provision_account(params) - assert {:ok, _} = provision_support_by_account_slug("test_account") + assert actor.name == "Firezone Support" + assert actor.account_id == account.id + + assert identity.provider_identifier == "ent-support@firezone.dev" + assert identity.account_id == account.id + assert identity.actor_id == actor.id end end - describe "provision_account/1" do + describe "create_and_provision_account/1" do setup do Domain.Config.put_env_override(:outbound_email_adapter_configured?, true) end test "provisions an account when valid input is provided" do + Bypass.open() + |> Mocks.Stripe.mock_create_customer_endpoint(%{id: nil, name: "Test Account"}) + |> Mocks.Stripe.mock_create_subscription_endpoint() + params = %{ - account_name: "Test Account", - account_slug: "test_account", - account_admin_name: "Test Admin", - account_admin_email: "test_admin@firezone.local" + name: "Test Account", + slug: "test_account", + admin_name: "Test Admin", + admin_email: "test_admin@firezone.local" } - assert {:ok, _} = provision_account(params) - assert {:ok, _} = Domain.Accounts.fetch_account_by_id_or_slug("test_account") + assert {:ok, + %{ + account: account, + provider: provider, + actor: actor, + identity: identity + }} = create_and_provision_account(params) + + assert account.name == "Test Account" + assert account.slug == "test_account" + assert account.metadata.stripe.customer_id + + assert actor.name == "Test Admin" + assert actor.account_id == account.id + + assert identity.provider_identifier == "test_admin@firezone.local" + assert identity.account_id == account.id + assert identity.actor_id == actor.id + assert identity.provider_id == provider.id + + assert provider.name == "Email" + assert provider.adapter == :email + + assert {:ok, account} = Domain.Accounts.fetch_account_by_id_or_slug("test_account") + assert account.name == "Test Account" + assert account.metadata.stripe.customer_id end test "returns an error when invalid input is provided" do params = %{ - account_name: "Test Account", - account_slug: "test_account", - account_admin_name: "Test Admin", - account_admin_email: "invalid" + name: "Test Account", + slug: "test_account", + admin_name: "Test Admin", + admin_email: "invalid" } - # provision_account/1 catches the invalid params and raises MatchError - assert_raise(MatchError, fn -> provision_account(params) end) + # create_and_provision_account/1 catches the invalid params and raises MatchError + assert_raise MatchError, fn -> + create_and_provision_account(params) + end end end end diff --git a/elixir/apps/domain/test/support/fixtures/accounts.ex b/elixir/apps/domain/test/support/fixtures/accounts.ex index a3b8a4c09..dca8d5126 100644 --- a/elixir/apps/domain/test/support/fixtures/accounts.ex +++ b/elixir/apps/domain/test/support/fixtures/accounts.ex @@ -1,5 +1,6 @@ defmodule Domain.Fixtures.Accounts do use Domain.Fixture + alias Domain.Repo alias Domain.Accounts def account_attrs(attrs \\ %{}) do @@ -7,7 +8,27 @@ defmodule Domain.Fixtures.Accounts do Enum.into(attrs, %{ name: "acc-#{unique_num}", - slug: "acc_#{unique_num}" + slug: "acc_#{unique_num}", + config: %{ + clients_upstream_dns: [ + %{protocol: "ip_port", address: "1.1.1.1"}, + %{protocol: "ip_port", address: "2606:4700:4700::1111"}, + %{protocol: "ip_port", address: "8.8.8.8:853"} + ] + }, + features: %{ + flow_activities: true, + multi_site_resources: true, + traffic_filters: true, + self_hosted_relays: true, + idp_sync: true + }, + limits: %{ + monthly_active_users_count: 100 + }, + metadata: %{ + stripe: %{} + } }) end @@ -16,4 +37,10 @@ defmodule Domain.Fixtures.Accounts do {:ok, account} = Accounts.create_account(attrs) account end + + def update_account(account, attrs \\ %{}) do + account + |> Ecto.Changeset.change(attrs) + |> Repo.update!() + end end diff --git a/elixir/apps/domain/test/support/fixtures/auth.ex b/elixir/apps/domain/test/support/fixtures/auth.ex index 53859e45f..ddbd35ac2 100644 --- a/elixir/apps/domain/test/support/fixtures/auth.ex +++ b/elixir/apps/domain/test/support/fixtures/auth.ex @@ -448,7 +448,7 @@ defmodule Domain.Fixtures.Auth do {identity, attrs} = pop_assoc_fixture(attrs, :identity, fn assoc_attrs -> - if actor.type == :service_account do + if actor.type in [:service_account, :api_client] do nil else assoc_attrs @@ -467,10 +467,17 @@ defmodule Domain.Fixtures.Auth do DateTime.utc_now() |> DateTime.add(60, :second) end) + context_type = + case actor.type do + :service_account -> :client + :api_client -> :api_client + _ -> :browser + end + {context, attrs} = pop_assoc_fixture(attrs, :context, fn assoc_attrs -> assoc_attrs - |> Enum.into(%{type: if(actor.type == :service_account, do: :client, else: :browser)}) + |> Enum.into(%{type: context_type}) |> build_context() end) diff --git a/elixir/apps/domain/test/support/fixtures/config.ex b/elixir/apps/domain/test/support/fixtures/config.ex deleted file mode 100644 index bcc216324..000000000 --- a/elixir/apps/domain/test/support/fixtures/config.ex +++ /dev/null @@ -1,33 +0,0 @@ -defmodule Domain.Fixtures.Config do - use Domain.Fixture - alias Domain.Config - - def configuration_attrs(attrs \\ %{}) do - Enum.into(attrs, %{ - clients_upstream_dns: [ - %{protocol: "ip_port", address: "1.1.1.1"}, - %{protocol: "ip_port", address: "2606:4700:4700::1111"}, - %{protocol: "ip_port", address: "8.8.8.8:853"} - ] - }) - end - - def upsert_configuration(attrs \\ %{}) do - attrs = configuration_attrs(attrs) - - {account, attrs} = - pop_assoc_fixture(attrs, :account, fn assoc_attrs -> - Fixtures.Accounts.create_account(assoc_attrs) - end) - - {:ok, configuration} = - Config.get_account_config_by_account_id(account.id) - |> Config.update_config(attrs) - - configuration - end - - def set_config(account, key, value) do - upsert_configuration([{:account, account}, {key, value}]) - end -end diff --git a/elixir/apps/domain/test/support/mocks/stripe.ex b/elixir/apps/domain/test/support/mocks/stripe.ex new file mode 100644 index 000000000..93caeb293 --- /dev/null +++ b/elixir/apps/domain/test/support/mocks/stripe.ex @@ -0,0 +1,378 @@ +defmodule Domain.Mocks.Stripe do + alias Domain.Billing.Stripe.APIClient + + def override_endpoint_url(url) do + config = Domain.Config.fetch_env!(:domain, APIClient) + config = Keyword.put(config, :endpoint, url) + Domain.Config.put_env_override(:domain, APIClient, config) + end + + def mock_create_customer_endpoint(bypass, account, resp \\ %{}) do + customers_endpoint_path = "v1/customers" + + resp = + Map.merge( + %{ + "id" => "cus_NffrFeUfNV2Hib", + "object" => "customer", + "address" => nil, + "balance" => 0, + "created" => 1_680_893_993, + "currency" => nil, + "default_source" => nil, + "delinquent" => false, + "description" => nil, + "discount" => nil, + "email" => nil, + "invoice_prefix" => "0759376C", + "invoice_settings" => %{ + "custom_fields" => nil, + "default_payment_method" => nil, + "footer" => nil, + "rendering_options" => nil + }, + "livemode" => false, + "metadata" => %{ + "account_id" => account.id + }, + "name" => account.name, + "next_invoice_sequence" => 1, + "phone" => nil, + "preferred_locales" => [], + "shipping" => nil, + "tax_exempt" => "none", + "test_clock" => nil + }, + resp + ) + + test_pid = self() + + Bypass.expect(bypass, "POST", customers_endpoint_path, fn conn -> + conn = Plug.Conn.fetch_query_params(conn) + conn = fetch_request_params(conn) + send(test_pid, {:bypass_request, conn}) + Plug.Conn.send_resp(conn, 200, Jason.encode!(resp)) + end) + + override_endpoint_url("http://localhost:#{bypass.port}") + + bypass + end + + def mock_fetch_customer_endpoint(bypass, account, resp \\ %{}) do + customer_endpoint_path = "v1/customers/#{account.metadata.stripe.customer_id}" + + resp = + Map.merge( + %{ + "id" => "cus_NffrFeUfNV2Hib", + "object" => "customer", + "address" => nil, + "balance" => 0, + "created" => 1_680_893_993, + "currency" => nil, + "default_source" => nil, + "delinquent" => false, + "description" => nil, + "discount" => nil, + "email" => nil, + "invoice_prefix" => "0759376C", + "invoice_settings" => %{ + "custom_fields" => nil, + "default_payment_method" => nil, + "footer" => nil, + "rendering_options" => nil + }, + "livemode" => false, + "metadata" => %{ + "account_id" => account.id + }, + "name" => account.name, + "next_invoice_sequence" => 1, + "phone" => nil, + "preferred_locales" => [], + "shipping" => nil, + "tax_exempt" => "none", + "test_clock" => nil + }, + resp + ) + + test_pid = self() + + Bypass.expect(bypass, "GET", customer_endpoint_path, fn conn -> + conn = Plug.Conn.fetch_query_params(conn) + send(test_pid, {:bypass_request, conn}) + Plug.Conn.send_resp(conn, 200, Jason.encode!(resp)) + end) + + override_endpoint_url("http://localhost:#{bypass.port}") + + bypass + end + + def mock_fetch_product_endpoint(bypass, product_id, resp \\ %{}) do + product_endpoint_path = "v1/products/#{product_id}" + + resp = + Map.merge( + %{ + "id" => product_id, + "object" => "product", + "active" => true, + "created" => 1_678_833_149, + "default_price" => nil, + "description" => nil, + "images" => [], + "features" => [], + "livemode" => false, + "metadata" => %{}, + "name" => "Enterprise", + "package_dimensions" => nil, + "shippable" => nil, + "statement_descriptor" => nil, + "tax_code" => nil, + "unit_label" => nil, + "updated" => 1_678_833_149, + "url" => nil + }, + resp + ) + + test_pid = self() + + Bypass.expect(bypass, "GET", product_endpoint_path, fn conn -> + conn = Plug.Conn.fetch_query_params(conn) + send(test_pid, {:bypass_request, conn}) + Plug.Conn.send_resp(conn, 200, Jason.encode!(resp)) + end) + + override_endpoint_url("http://localhost:#{bypass.port}") + + bypass + end + + def mock_create_billing_session_endpoint(bypass, account, resp \\ %{}) do + customers_endpoint_path = "v1/billing_portal/sessions" + + resp = + Map.merge( + %{ + "id" => "bps_1MrSjzLkdIwHu7ixex0IvU9b", + "object" => "billing_portal.session", + "configuration" => "bpc_1MAhNDLkdIwHu7ixckACO1Jq", + "created" => 1_680_210_639, + "customer" => account.metadata.stripe.customer_id, + "flow" => nil, + "livemode" => false, + "locale" => nil, + "on_behalf_of" => nil, + "return_url" => "https://example.com/account", + "url" => + "https://billing.stripe.com/p/session/test_YWNjdF8xTTJKVGtMa2RJd0h1N2l4LF9OY2lBYjJXcHY4a2NPck96UjBEbFVYRnU5bjlwVUF50100BUtQs3bl" + }, + resp + ) + + test_pid = self() + + Bypass.expect(bypass, "POST", customers_endpoint_path, fn conn -> + conn = Plug.Conn.fetch_query_params(conn) + conn = fetch_request_params(conn) + send(test_pid, {:bypass_request, conn}) + Plug.Conn.send_resp(conn, 200, Jason.encode!(resp)) + end) + + override_endpoint_url("http://localhost:#{bypass.port}") + + bypass + end + + def mock_create_subscription_endpoint(bypass, resp \\ %{}) do + customers_endpoint_path = "v1/subscriptions" + + resp = + Map.merge( + subscription_object("cus_NffrFeUfNV2Hib", %{}, %{}, 1), + resp + ) + + test_pid = self() + + Bypass.expect(bypass, "POST", customers_endpoint_path, fn conn -> + conn = Plug.Conn.fetch_query_params(conn) + conn = fetch_request_params(conn) + send(test_pid, {:bypass_request, conn}) + Plug.Conn.send_resp(conn, 200, Jason.encode!(resp)) + end) + + override_endpoint_url("http://localhost:#{bypass.port}") + + bypass + end + + def subscription_object(customer_id, subscription_metadata, plan_metadata, quantity) do + %{ + "id" => "sub_1MowQVLkdIwHu7ixeRlqHVzs", + "object" => "subscription", + "application" => nil, + "application_fee_percent" => nil, + "automatic_tax" => %{ + "enabled" => false, + "liability" => nil + }, + "billing_cycle_anchor" => 1_679_609_767, + "billing_thresholds" => nil, + "cancel_at" => nil, + "cancel_at_period_end" => false, + "canceled_at" => nil, + "cancellation_details" => %{ + "comment" => nil, + "feedback" => nil, + "reason" => nil + }, + "collection_method" => "charge_automatically", + "created" => 1_679_609_767, + "currency" => "usd", + "current_period_end" => 1_682_288_167, + "current_period_start" => 1_679_609_767, + "customer" => customer_id, + "days_until_due" => nil, + "default_payment_method" => nil, + "default_source" => nil, + "default_tax_rates" => [], + "description" => nil, + "discount" => nil, + "ended_at" => nil, + "invoice_settings" => %{ + "issuer" => %{ + "type" => "self" + } + }, + "items" => %{ + "object" => "list", + "data" => [ + %{ + "id" => "si_Na6dzxczY5fwHx", + "object" => "subscription_item", + "billing_thresholds" => nil, + "created" => 1_679_609_768, + "metadata" => %{}, + "plan" => %{ + "id" => "price_1MowQULkdIwHu7ixraBm864M", + "object" => "plan", + "active" => true, + "aggregate_usage" => nil, + "amount" => 1000, + "amount_decimal" => "1000", + "billing_scheme" => "per_unit", + "created" => 1_679_609_766, + "currency" => "usd", + "interval" => "month", + "interval_count" => 1, + "livemode" => false, + "metadata" => plan_metadata, + "nickname" => nil, + "product" => "prod_Na6dGcTsmU0I4R", + "tiers_mode" => nil, + "transform_usage" => nil, + "trial_period_days" => nil, + "usage_type" => "licensed" + }, + "price" => %{ + "id" => "price_1MowQULkdIwHu7ixraBm864M", + "object" => "price", + "active" => true, + "billing_scheme" => "per_unit", + "created" => 1_679_609_766, + "currency" => "usd", + "custom_unit_amount" => nil, + "livemode" => false, + "lookup_key" => nil, + "metadata" => %{}, + "nickname" => nil, + "product" => "prod_Na6dGcTsmU0I4R", + "recurring" => %{ + "aggregate_usage" => nil, + "interval" => "month", + "interval_count" => 1, + "trial_period_days" => nil, + "usage_type" => "licensed" + }, + "tax_behavior" => "unspecified", + "tiers_mode" => nil, + "transform_quantity" => nil, + "type" => "recurring", + "unit_amount" => 1000, + "unit_amount_decimal" => "1000" + }, + "quantity" => quantity, + "subscription" => "sub_1MowQVLkdIwHu7ixeRlqHVzs", + "tax_rates" => [] + } + ], + "has_more" => false, + "total_count" => 1, + "url" => "/v1/subscription_items?subscription=sub_1MowQVLkdIwHu7ixeRlqHVzs" + }, + "latest_invoice" => "in_1MowQWLkdIwHu7ixuzkSPfKd", + "livemode" => false, + "metadata" => subscription_metadata, + "next_pending_invoice_item_invoice" => nil, + "on_behalf_of" => nil, + "pause_collection" => nil, + "payment_settings" => %{ + "payment_method_options" => nil, + "payment_method_types" => nil, + "save_default_payment_method" => "off" + }, + "pending_invoice_item_interval" => nil, + "pending_setup_intent" => nil, + "pending_update" => nil, + "quantity" => 1, + "schedule" => nil, + "start_date" => 1_679_609_767, + "status" => "active", + "test_clock" => nil, + "transfer_data" => nil, + "trial_end" => nil, + "trial_settings" => %{ + "end_behavior" => %{ + "missing_payment_method" => "create_invoice" + } + }, + "trial_start" => nil + } + end + + def build_event(type, object) do + %{ + "id" => "evt_1NG8Du2eZvKYlo2CUI79vXWy", + "object" => "event", + "api_version" => "2019-02-19", + "created" => 1_686_089_970, + "data" => %{ + "object" => object + }, + "livemode" => false, + "pending_webhooks" => 0, + "request" => %{ + "id" => nil, + "idempotency_key" => nil + }, + "type" => type + } + end + + defp fetch_request_params(conn) do + opts = + Plug.Parsers.init( + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + json_decoder: Phoenix.json_library() + ) + + Plug.Parsers.call(conn, opts) + end +end diff --git a/elixir/apps/web/lib/web/components/core_components.ex b/elixir/apps/web/lib/web/components/core_components.ex index dcbf59413..ff9c95bdf 100644 --- a/elixir/apps/web/lib/web/components/core_components.ex +++ b/elixir/apps/web/lib/web/components/core_components.ex @@ -328,7 +328,11 @@ defmodule Web.CoreComponents do attr :id, :string, default: "flash", doc: "the optional id of flash container" attr :flash, :map, default: %{}, doc: "the map of flash messages to display" attr :title, :string, default: nil - attr :kind, :atom, values: [:success, :info, :error], doc: "used for styling and flash lookup" + + attr :kind, :atom, + values: [:success, :info, :warning, :error], + doc: "used for styling and flash lookup" + attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container" attr :style, :string, default: "pill" @@ -1168,4 +1172,52 @@ defmodule Web.CoreComponents do end def provider_icon(assigns), do: ~H"" + + def feature_name(%{feature: :idp_sync} = assigns) do + ~H""" + Automatically sync users and groups + """ + end + + def feature_name(%{feature: :flow_activities} = assigns) do + ~H""" + See detailed flow activities (beta) + """ + end + + def feature_name(%{feature: :multi_site_resources} = assigns) do + ~H""" + Define globally-distributed resources (beta) + """ + end + + def feature_name(%{feature: :traffic_filters} = assigns) do + ~H""" + Filter traffic using protocol and port rules (beta) + """ + end + + def feature_name(%{feature: :self_hosted_relays} = assigns) do + ~H""" + Host your own relays (beta) + """ + end + + def feature_name(assigns) do + ~H"" + end + + def mailto_support(account, subject, email_subject) do + body = + """ + + + --- + Please do not remove this part of the email. + Account ID: #{account.id} + Actor ID: #{subject.actor.id} + """ + + "mailto:support@firezone.dev?subject=#{URI.encode_www_form(email_subject)}&body=#{URI.encode_www_form(body)}" + end end diff --git a/elixir/apps/web/lib/web/components/form_components.ex b/elixir/apps/web/lib/web/components/form_components.ex index 605a1b217..1b0f99790 100644 --- a/elixir/apps/web/lib/web/components/form_components.ex +++ b/elixir/apps/web/lib/web/components/form_components.ex @@ -267,7 +267,7 @@ defmodule Web.FormComponents do ]}> <.sidebar_item - :if={Domain.Config.self_hosted_relays_enabled?()} + :if={Domain.Accounts.self_hosted_relays_enabled?(@account)} current_path={@current_path} navigate={~p"/#{@account}/relay_groups"} icon="hero-arrows-right-left" @@ -62,6 +62,12 @@ <:name>Settings <:item navigate={~p"/#{@account}/settings/account"}>Account + <:item + :if={Domain.Billing.account_provisioned?(@account)} + navigate={~p"/#{@account}/settings/billing"} + > + Billing + <:item navigate={~p"/#{@account}/settings/identity_providers"}> Identity Providers @@ -80,5 +86,30 @@
+ <.flash :if={@account.warning} kind={:warning}> + <%= @account.warning %>. + + Please + <.link navigate={~p"/#{@account}/settings/billing"} class={link_style()}> + check your billing information + + to continue using Firezone. + + + + <.flash :if={not Domain.Accounts.account_active?(@account)} kind={:error}> + This account has been disabled. + + Please + <.link + class={link_style()} + href={mailto_support(@account, @subject, "Enable account: #{@account.name}")} + > + contact support + + to re-activate it. + + + <%= @inner_content %>
diff --git a/elixir/apps/web/lib/web/controllers/home_html.ex b/elixir/apps/web/lib/web/controllers/home_html.ex index 474a2d69e..518ca7bba 100644 --- a/elixir/apps/web/lib/web/controllers/home_html.ex +++ b/elixir/apps/web/lib/web/controllers/home_html.ex @@ -37,21 +37,22 @@ defmodule Web.HomeHTML do type="text" label="Account ID or Slug" prefix={url(~p"/")} + placeholder="Enter account ID from the welcome email" required autofocus /> -

Your account ID can be found in your welcome email.

<.button class="w-full"> Go to Sign In page +

Don't have an account? diff --git a/elixir/apps/web/lib/web/endpoint.ex b/elixir/apps/web/lib/web/endpoint.ex index 7ab9e3e11..29f2d4c94 100644 --- a/elixir/apps/web/lib/web/endpoint.ex +++ b/elixir/apps/web/lib/web/endpoint.ex @@ -9,6 +9,7 @@ defmodule Web.Endpoint do plug Plug.RewriteOn, [:x_forwarded_host, :x_forwarded_port, :x_forwarded_proto] plug Plug.MethodOverride + plug :put_hsts_header plug Web.Plugs.SecureHeaders plug RemoteIp, @@ -63,6 +64,22 @@ defmodule Web.Endpoint do plug Web.Router + def put_hsts_header(conn, _opts) do + scheme = + config(:url, []) + |> Keyword.get(:scheme) + + if scheme == "https" do + put_resp_header( + conn, + "strict-transport-security", + "max-age=63072000; includeSubDomains; preload" + ) + else + conn + end + end + def real_ip_opts do [ headers: ["x-forwarded-for"], diff --git a/elixir/apps/web/lib/web/live/actors/service_accounts/new.ex b/elixir/apps/web/lib/web/live/actors/service_accounts/new.ex index 3f7806c17..f3554b050 100644 --- a/elixir/apps/web/lib/web/live/actors/service_accounts/new.ex +++ b/elixir/apps/web/lib/web/live/actors/service_accounts/new.ex @@ -84,6 +84,22 @@ defmodule Web.Actors.ServiceAccounts.New do {:noreply, socket} else + {:error, :service_accounts_limit_reached} -> + changeset = + attrs + |> Actors.new_actor() + |> Map.put(:action, :insert) + + socket = + socket + |> put_flash( + :error, + "You have reached the maximum number of service accounts allowed by your subscription plan." + ) + |> assign(form: to_form(changeset)) + + {:noreply, socket} + {:error, changeset} -> {:noreply, assign(socket, form: to_form(changeset))} end diff --git a/elixir/apps/web/lib/web/live/actors/show.ex b/elixir/apps/web/lib/web/live/actors/show.ex index 738ccd2f8..45d7b97b5 100644 --- a/elixir/apps/web/lib/web/live/actors/show.ex +++ b/elixir/apps/web/lib/web/live/actors/show.ex @@ -1,7 +1,7 @@ defmodule Web.Actors.Show do use Web, :live_view import Web.Actors.Components - alias Domain.{Auth, Tokens, Flows, Clients} + alias Domain.{Accounts, Auth, Tokens, Flows, Clients} alias Domain.Actors def mount(%{"id" => id}, _token, socket) do @@ -30,7 +30,7 @@ defmodule Web.Actors.Show do flows: flows, tokens: tokens, page_title: "Actor #{actor.name}", - flow_activities_enabled?: Domain.Config.flow_activities_enabled?() + flow_activities_enabled?: Accounts.flow_activities_enabled?(socket.assigns.account) )} else _other -> raise Web.LiveErrors.NotFoundError diff --git a/elixir/apps/web/lib/web/live/actors/users/new.ex b/elixir/apps/web/lib/web/live/actors/users/new.ex index 4a2dbccf3..a69d58d22 100644 --- a/elixir/apps/web/lib/web/live/actors/users/new.ex +++ b/elixir/apps/web/lib/web/live/actors/users/new.ex @@ -76,6 +76,22 @@ defmodule Web.Actors.Users.New do {:noreply, socket} else + {:error, :seats_limit_reached} -> + changeset = + attrs + |> Actors.new_actor() + |> Map.put(:action, :insert) + + socket = + socket + |> put_flash( + :error, + "You have reached the maximum number of seats allowed by your subscription plan." + ) + |> assign(form: to_form(changeset)) + + {:noreply, socket} + {:error, changeset} -> {:noreply, assign(socket, form: to_form(changeset))} end diff --git a/elixir/apps/web/lib/web/live/clients/show.ex b/elixir/apps/web/lib/web/live/clients/show.ex index ead1c3a54..2c6bd06e3 100644 --- a/elixir/apps/web/lib/web/live/clients/show.ex +++ b/elixir/apps/web/lib/web/live/clients/show.ex @@ -1,7 +1,7 @@ defmodule Web.Clients.Show do use Web, :live_view import Web.Policies.Components - alias Domain.{Clients, Flows, Config} + alias Domain.{Accounts, Clients, Flows} def mount(%{"id" => id}, _session, socket) do with {:ok, client} <- @@ -19,7 +19,7 @@ defmodule Web.Clients.Show do socket, client: client, flows: flows, - flow_activities_enabled?: Config.flow_activities_enabled?(), + flow_activities_enabled?: Accounts.flow_activities_enabled?(socket.assigns.account), page_title: "Client #{client.name}" ) diff --git a/elixir/apps/web/lib/web/live/policies/show.ex b/elixir/apps/web/lib/web/live/policies/show.ex index 373fc9eb5..d8da87b72 100644 --- a/elixir/apps/web/lib/web/live/policies/show.ex +++ b/elixir/apps/web/lib/web/live/policies/show.ex @@ -1,7 +1,7 @@ defmodule Web.Policies.Show do use Web, :live_view import Web.Policies.Components - alias Domain.{Policies, Flows, Config} + alias Domain.{Accounts, Policies, Flows} def mount(%{"id" => id}, _session, socket) do with {:ok, policy} <- @@ -19,7 +19,7 @@ defmodule Web.Policies.Show do policy: policy, flows: flows, page_title: "Policy #{policy.id}", - flow_activities_enabled?: Config.flow_activities_enabled?() + flow_activities_enabled?: Accounts.flow_activities_enabled?(socket.assigns.account) ) {:ok, socket} diff --git a/elixir/apps/web/lib/web/live/relay_groups/edit.ex b/elixir/apps/web/lib/web/live/relay_groups/edit.ex index ea571a3d0..e1206fd5c 100644 --- a/elixir/apps/web/lib/web/live/relay_groups/edit.ex +++ b/elixir/apps/web/lib/web/live/relay_groups/edit.ex @@ -1,9 +1,9 @@ defmodule Web.RelayGroups.Edit do use Web, :live_view - alias Domain.Relays + alias Domain.{Accounts, Relays} def mount(%{"id" => id}, _session, socket) do - with true <- Domain.Config.self_hosted_relays_enabled?(), + with true <- Accounts.self_hosted_relays_enabled?(socket.assigns.account), {:ok, group} <- Relays.fetch_group_by_id(id, socket.assigns.subject), nil <- group.deleted_at do changeset = Relays.change_group(group) diff --git a/elixir/apps/web/lib/web/live/relay_groups/index.ex b/elixir/apps/web/lib/web/live/relay_groups/index.ex index 9ecd0bfec..045d01f12 100644 --- a/elixir/apps/web/lib/web/live/relay_groups/index.ex +++ b/elixir/apps/web/lib/web/live/relay_groups/index.ex @@ -1,11 +1,11 @@ defmodule Web.RelayGroups.Index do use Web, :live_view - alias Domain.Relays + alias Domain.{Accounts, Relays} def mount(_params, _session, socket) do subject = socket.assigns.subject - with true <- Domain.Config.self_hosted_relays_enabled?(), + with true <- Accounts.self_hosted_relays_enabled?(socket.assigns.account), {:ok, groups} <- Relays.list_groups(subject, preload: [:relays]) do :ok = Relays.subscribe_to_relays_presence_in_account(socket.assigns.account) diff --git a/elixir/apps/web/lib/web/live/relay_groups/new.ex b/elixir/apps/web/lib/web/live/relay_groups/new.ex index 1c4486908..4392866b2 100644 --- a/elixir/apps/web/lib/web/live/relay_groups/new.ex +++ b/elixir/apps/web/lib/web/live/relay_groups/new.ex @@ -1,9 +1,9 @@ defmodule Web.RelayGroups.New do use Web, :live_view - alias Domain.Relays + alias Domain.{Accounts, Relays} def mount(_params, _session, socket) do - with true <- Domain.Config.self_hosted_relays_enabled?() do + with true <- Accounts.self_hosted_relays_enabled?(socket.assigns.account) do changeset = Relays.new_group() {:ok, assign(socket, form: to_form(changeset, page_title: "New Relay Group")), diff --git a/elixir/apps/web/lib/web/live/relay_groups/new_token.ex b/elixir/apps/web/lib/web/live/relay_groups/new_token.ex index 7a61ef0b0..0398298f0 100644 --- a/elixir/apps/web/lib/web/live/relay_groups/new_token.ex +++ b/elixir/apps/web/lib/web/live/relay_groups/new_token.ex @@ -1,9 +1,9 @@ defmodule Web.RelayGroups.NewToken do use Web, :live_view - alias Domain.Relays + alias Domain.{Accounts, Relays} def mount(%{"id" => id}, _session, socket) do - with true <- Domain.Config.self_hosted_relays_enabled?(), + with true <- Accounts.self_hosted_relays_enabled?(socket.assigns.account), {:ok, group} <- Relays.fetch_group_by_id(id, socket.assigns.subject) do {group, env} = if connected?(socket) do diff --git a/elixir/apps/web/lib/web/live/relay_groups/show.ex b/elixir/apps/web/lib/web/live/relay_groups/show.ex index e4fe28d9e..812b61542 100644 --- a/elixir/apps/web/lib/web/live/relay_groups/show.ex +++ b/elixir/apps/web/lib/web/live/relay_groups/show.ex @@ -1,9 +1,9 @@ defmodule Web.RelayGroups.Show do use Web, :live_view - alias Domain.{Relays, Tokens} + alias Domain.{Accounts, Relays, Tokens} def mount(%{"id" => id}, _session, socket) do - with true <- Domain.Config.self_hosted_relays_enabled?(), + with true <- Accounts.self_hosted_relays_enabled?(socket.assigns.account), {:ok, group} <- Relays.fetch_group_by_id(id, socket.assigns.subject, preload: [ diff --git a/elixir/apps/web/lib/web/live/relays/show.ex b/elixir/apps/web/lib/web/live/relays/show.ex index b4eee1929..7edbdfb82 100644 --- a/elixir/apps/web/lib/web/live/relays/show.ex +++ b/elixir/apps/web/lib/web/live/relays/show.ex @@ -1,9 +1,9 @@ defmodule Web.Relays.Show do use Web, :live_view - alias Domain.{Relays, Config} + alias Domain.{Accounts, Relays} def mount(%{"id" => id}, _session, socket) do - with true <- Config.self_hosted_relays_enabled?(), + with true <- Accounts.self_hosted_relays_enabled?(socket.assigns.account), {:ok, relay} <- Relays.fetch_relay_by_id(id, socket.assigns.subject, preload: :group) do :ok = Relays.subscribe_to_relays_presence_in_group(relay.group) diff --git a/elixir/apps/web/lib/web/live/resources/components.ex b/elixir/apps/web/lib/web/live/resources/components.ex index b37ae1257..2a65c259e 100644 --- a/elixir/apps/web/lib/web/live/resources/components.ex +++ b/elixir/apps/web/lib/web/live/resources/components.ex @@ -4,9 +4,9 @@ defmodule Web.Resources.Components do defp pretty_print_ports([]), do: "" defp pretty_print_ports(ports), do: Enum.join(ports, ", ") - def map_filters_form_attrs(attrs) do + def map_filters_form_attrs(attrs, account) do attrs = - if Domain.Config.traffic_filters_enabled?() do + if Domain.Accounts.traffic_filters_enabled?(account) do attrs else Map.put(attrs, "filters", %{"all" => %{"enabled" => "true", "protocol" => "all"}}) diff --git a/elixir/apps/web/lib/web/live/resources/edit.ex b/elixir/apps/web/lib/web/live/resources/edit.ex index fb9289b08..ef6d161ed 100644 --- a/elixir/apps/web/lib/web/live/resources/edit.ex +++ b/elixir/apps/web/lib/web/live/resources/edit.ex @@ -1,7 +1,7 @@ defmodule Web.Resources.Edit do use Web, :live_view import Web.Resources.Components - alias Domain.{Gateways, Resources, Config} + alias Domain.{Accounts, Gateways, Resources} def mount(%{"id" => id} = params, _session, socket) do with {:ok, resource} <- @@ -17,7 +17,7 @@ defmodule Web.Resources.Edit do gateway_groups: gateway_groups, form: form, params: Map.take(params, ["site_id"]), - traffic_filters_enabled?: Config.traffic_filters_enabled?(), + traffic_filters_enabled?: Accounts.traffic_filters_enabled?(socket.assigns.account), page_title: "Edit #{resource.name}" ) @@ -92,7 +92,7 @@ defmodule Web.Resources.Edit do def handle_event("change", %{"resource" => attrs}, socket) do attrs = attrs - |> map_filters_form_attrs() + |> map_filters_form_attrs(socket.assigns.account) |> map_connections_form_attrs() |> maybe_delete_connections(socket.assigns.params) @@ -106,7 +106,7 @@ defmodule Web.Resources.Edit do def handle_event("submit", %{"resource" => attrs}, socket) do attrs = attrs - |> map_filters_form_attrs() + |> map_filters_form_attrs(socket.assigns.account) |> map_connections_form_attrs() |> maybe_delete_connections(socket.assigns.params) diff --git a/elixir/apps/web/lib/web/live/resources/index.ex b/elixir/apps/web/lib/web/live/resources/index.ex index 78a670ce3..8ed0ac0ea 100644 --- a/elixir/apps/web/lib/web/live/resources/index.ex +++ b/elixir/apps/web/lib/web/live/resources/index.ex @@ -44,7 +44,7 @@ defmodule Web.Resources.Index do <:action> <.add_button - :if={Domain.Config.multi_site_resources_enabled?()} + :if={Domain.Accounts.multi_site_resources_enabled?(@account)} navigate={~p"/#{@account}/resources/new"} > Add Multi-Site Resource diff --git a/elixir/apps/web/lib/web/live/resources/new.ex b/elixir/apps/web/lib/web/live/resources/new.ex index 8b52601da..b4b32fcd4 100644 --- a/elixir/apps/web/lib/web/live/resources/new.ex +++ b/elixir/apps/web/lib/web/live/resources/new.ex @@ -1,7 +1,7 @@ defmodule Web.Resources.New do use Web, :live_view import Web.Resources.Components - alias Domain.{Gateways, Resources, Config} + alias Domain.{Accounts, Gateways, Resources} def mount(params, _session, socket) do with {:ok, gateway_groups} <- Gateways.list_groups(socket.assigns.subject) do @@ -15,7 +15,7 @@ defmodule Web.Resources.New do name_changed?: false, form: to_form(changeset), params: Map.take(params, ["site_id"]), - traffic_filters_enabled?: Config.traffic_filters_enabled?(), + traffic_filters_enabled?: Accounts.traffic_filters_enabled?(socket.assigns.account), page_title: "New Resource" ) @@ -165,7 +165,7 @@ defmodule Web.Resources.New do attrs |> maybe_put_default_name(name_changed?) |> maybe_put_default_address_description(address_description_changed?) - |> map_filters_form_attrs() + |> map_filters_form_attrs(socket.assigns.account) |> map_connections_form_attrs() |> maybe_put_connections(socket.assigns.params) @@ -188,7 +188,7 @@ defmodule Web.Resources.New do attrs |> maybe_put_default_name() |> maybe_put_default_address_description() - |> map_filters_form_attrs() + |> map_filters_form_attrs(socket.assigns.account) |> map_connections_form_attrs() |> maybe_put_connections(socket.assigns.params) diff --git a/elixir/apps/web/lib/web/live/resources/show.ex b/elixir/apps/web/lib/web/live/resources/show.ex index 1b8154d15..b5dc8542a 100644 --- a/elixir/apps/web/lib/web/live/resources/show.ex +++ b/elixir/apps/web/lib/web/live/resources/show.ex @@ -1,7 +1,7 @@ defmodule Web.Resources.Show do use Web, :live_view import Web.Policies.Components - alias Domain.{Resources, Flows, Config} + alias Domain.{Accounts, Resources, Flows} def mount(%{"id" => id} = params, _session, socket) do with {:ok, resource} <- @@ -23,7 +23,7 @@ defmodule Web.Resources.Show do actor_groups_peek: Map.fetch!(actor_groups_peek, resource.id), flows: flows, params: Map.take(params, ["site_id"]), - traffic_filters_enabled?: Config.traffic_filters_enabled?(), + traffic_filters_enabled?: Accounts.traffic_filters_enabled?(socket.assigns.account), page_title: "Resource #{resource.name}" ) @@ -48,7 +48,7 @@ defmodule Web.Resources.Show do <:action :if={is_nil(@resource.deleted_at)}> <.edit_button - :if={Domain.Config.multi_site_resources_enabled?()} + :if={Domain.Accounts.multi_site_resources_enabled?(@account)} navigate={~p"/#{@account}/resources/#{@resource.id}/edit?#{@params}"} > Edit Resource diff --git a/elixir/apps/web/lib/web/live/settings/account.ex b/elixir/apps/web/lib/web/live/settings/account.ex index 41470c49a..098c8d4b5 100644 --- a/elixir/apps/web/lib/web/live/settings/account.ex +++ b/elixir/apps/web/lib/web/live/settings/account.ex @@ -1,8 +1,14 @@ defmodule Web.Settings.Account do use Web, :live_view + alias Domain.Accounts def mount(_params, _session, socket) do - {:ok, assign(socket, page_title: "Account")} + socket = + assign(socket, + page_title: "Account" + ) + + {:ok, socket} end def render(assigns) do @@ -16,26 +22,25 @@ defmodule Web.Settings.Account do Account Settings <:content> -

+ <.vertical_table id="account"> + <.vertical_table_row> + <:label>Account Name + <:value><%= @account.name %> + + <.vertical_table_row> + <:label>Account ID + <:value><%= @account.id %> + + <.vertical_table_row> + <:label>Account Slug + <:value> + <.copy id="account-slug"><%= @account.slug %> + + + + <.section> <:title> Danger zone @@ -45,10 +50,11 @@ defmodule Web.Settings.Account do Terminate account

- <.icon name="hero-exclamation-circle" class="inline-block w-5 h-5 mr-1 text-red-500" /> - To disable your account and schedule it for deletion, please <.link + <.icon name="hero-exclamation-circle" class="inline-block w-5 h-5 mr-1 text-red-500" /> To + disable your account and + schedule it for deletion, please <.link class={link_style()} - href="mailto:support@firezone.dev" + href={mailto_support(@account, @subject, "Account termination request: #{@account.name}")} >contact support.

diff --git a/elixir/apps/web/lib/web/live/settings/billing.ex b/elixir/apps/web/lib/web/live/settings/billing.ex new file mode 100644 index 000000000..1d082cbcc --- /dev/null +++ b/elixir/apps/web/lib/web/live/settings/billing.ex @@ -0,0 +1,215 @@ +defmodule Web.Settings.Billing do + use Web, :live_view + alias Domain.{Accounts, Actors, Clients, Gateways, Billing} + require Logger + + def mount(_params, _session, socket) do + unless Billing.account_provisioned?(socket.assigns.account), + do: raise(Web.LiveErrors.NotFoundError) + + admins_count = Actors.count_account_admin_users_for_account(socket.assigns.account) + service_accounts_count = Actors.count_service_accounts_for_account(socket.assigns.account) + active_users_count = Clients.count_1m_active_users_for_account(socket.assigns.account) + gateway_groups_count = Gateways.count_groups_for_account(socket.assigns.account) + + socket = + assign(socket, + error: nil, + page_title: "Billing", + admins_count: admins_count, + active_users_count: active_users_count, + service_accounts_count: service_accounts_count, + gateway_groups_count: gateway_groups_count + ) + + {:ok, socket} + end + + def render(assigns) do + ~H""" + <.breadcrumbs account={@account}> + <.breadcrumb path={~p"/#{@account}/settings/billing"}>Billing + + + <.section> + <:title> + Billing Information + + <:action> + <.button icon="hero-pencil" phx-click="redirect_to_billing_portal"> + Manage + + <.button navigate={ + mailto_support( + @account, + @subject, + "Billing question: #{@account.name}" + ) + }> + Contact Sales Team + + + <:content> + <.flash :if={@error} kind={:error}> +

<%= @error %>

+ +

+ If you need assistance, please <.link + class={link_style()} + href={ + mailto_support( + @account, + @subject, + "Issues accessing billing portal: #{@account.name}" + ) + } + >contact support. +

+ + + <.vertical_table id="billing"> + <.vertical_table_row> + <:label>Current Plan + <:value> + <%= @account.metadata.stripe.product_name %> + + + + <.vertical_table_row :if={not is_nil(@account.limits.monthly_active_users_count)}> + <:label> +

Seats

+ + <:value> + @account.limits.monthly_active_users_count && "text-red-500" + ]}> + <%= @active_users_count %> used + + / <%= @account.limits.monthly_active_users_count %> allowed +

users with at least one device signed-in within last month

+ + + + <.vertical_table_row :if={not is_nil(@account.limits.service_accounts_count)}> + <:label> +

Service Accounts

+ + <:value> + @account.limits.service_accounts_count && "text-red-500" + ]}> + <%= @service_accounts_count %> used + + / <%= @account.limits.service_accounts_count %> allowed +

users with at least one device signed-in within last month

+ + + + <.vertical_table_row :if={not is_nil(@account.limits.account_admin_users_count)}> + <:label> +

Admins

+ + <:value> + @account.limits.account_admin_users_count && "text-red-500" + ]}> + <%= @admins_count %> used + + / <%= @account.limits.account_admin_users_count %> allowed + + + + <.vertical_table_row :if={not is_nil(@account.limits.gateway_groups_count)}> + <:label> +

Sites

+ + <:value> + @account.limits.gateway_groups_count && "text-red-500" + ]}> + <%= @gateway_groups_count %> used + + / <%= @account.limits.gateway_groups_count %> allowed + + + + + + + <.section> + <:title> + Enabled Enterprise Features + + <:help> + For further details on enrolling in beta features, reach out to your account manager + + <:content> + <.vertical_table id="features"> + <.vertical_table_row :for={ + {key, _value} <- Map.delete(Map.from_struct(@account.features), :limits) + }> + <:label><.feature_name feature={key} /> + <:value> + <% value = apply(Domain.Accounts, :"#{key}_enabled?", [@account]) %> + <.icon + :if={value == true} + name="hero-check" + class="inline-block w-5 h-5 mr-1 text-green-500" + /> + <.icon + :if={value == false} + name="hero-x-mark" + class="inline-block w-5 h-5 mr-1 text-red-500" + /> + + + + + + + <.section> + <:title> + Danger zone + + <:content> +

+ Terminate account +

+

+ <.icon name="hero-exclamation-circle" class="inline-block w-5 h-5 mr-1 text-red-500" /> To + disable your account and + schedule it for deletion, please <.link + class={link_style()} + href={mailto_support(@account, @subject, "Account termination request: #{@account.name}")} + >contact support. +

+ + + """ + end + + def handle_event("redirect_to_billing_portal", _params, socket) do + with {:ok, billing_portal_url} <- + Billing.billing_portal_url( + socket.assigns.account, + url(~p"/#{socket.assigns.account}/settings/billing"), + socket.assigns.subject + ) do + {:noreply, redirect(socket, external: billing_portal_url)} + else + {:error, reason} -> + Logger.error("Failed to get billing portal URL", reason: inspect(reason)) + + socket = + assign(socket, + error: "Billing portal is temporarily unavailable, please try again later." + ) + + {:noreply, socket} + end + end +end diff --git a/elixir/apps/web/lib/web/live/settings/dns.ex b/elixir/apps/web/lib/web/live/settings/dns.ex index 059bd4923..9794cf66c 100644 --- a/elixir/apps/web/lib/web/live/settings/dns.ex +++ b/elixir/apps/web/lib/web/live/settings/dns.ex @@ -1,25 +1,26 @@ defmodule Web.Settings.DNS do use Web, :live_view - alias Domain.Config - alias Domain.Config.Configuration.ClientsUpstreamDNS + alias Domain.Accounts def mount(_params, _session, socket) do - {:ok, config} = Config.fetch_account_config(socket.assigns.subject) + account = Accounts.fetch_account_by_id!(socket.assigns.account.id) form = - Config.change_account_config(config, %{}) - |> add_new_server() + Accounts.change_account(account, %{}) + |> maybe_append_empty_embed() |> to_form() - socket = assign(socket, config: config, form: form, page_title: "DNS") + socket = + assign(socket, + account: account, + form: form, + page_title: "DNS" + ) {:ok, socket} end def render(assigns) do - assigns = - assign(assigns, :errors, translate_errors(assigns.form.errors, :clients_upstream_dns)) - ~H""" <.breadcrumbs account={@account}> <.breadcrumb path={~p"/#{@account}/settings/dns"}>DNS Settings @@ -56,26 +57,37 @@ defmodule Web.Settings.DNS do <.form for={@form} phx-submit={:submit} phx-change={:change}>
- <.inputs_for :let={dns} field={@form[:clients_upstream_dns]}> -
-
- <.input - type="select" - label="Protocol" - field={dns[:protocol]} - placeholder="Protocol" - options={dns_options()} - value={dns[:protocol].value} - /> + <.inputs_for :let={config} field={@form[:config]}> + <.inputs_for :let={dns} field={config[:clients_upstream_dns]}> +
+
+ <.input + type="select" + label="Protocol" + field={dns[:protocol]} + placeholder="Protocol" + options={dns_options()} + value={dns[:protocol].value} + /> +
+
+ <.input + label="Address" + field={dns[:address]} + placeholder="DNS Server Address" + /> +
-
- <.input label="Address" field={dns[:address]} placeholder="DNS Server Address" /> -
-
+ + <% errors = + translate_errors( + @form.source.changes.config.errors, + :clients_upstream_dns + ) %> + <.error :for={error <- errors} data-validation-error-for="clients_upstream_dns"> + <%= error %> + - <.error :for={msg <- @errors} data-validation-error-for="clients_upstream_dns"> - <%= msg %> -
<.submit_button> Save @@ -88,87 +100,102 @@ defmodule Web.Settings.DNS do """ end - def handle_event("change", %{"configuration" => config_params}, socket) do - form = - Config.change_account_config(socket.assigns.config, config_params) + def handle_event("change", %{"account" => attrs}, socket) do + changeset = + Accounts.change_account(socket.assigns.account, attrs) + |> maybe_append_empty_embed() |> filter_errors() |> Map.put(:action, :validate) - |> to_form() - socket = assign(socket, form: form) - {:noreply, socket} + {:noreply, assign(socket, form: to_form(changeset))} end - def handle_event("submit", %{"configuration" => config_params}, socket) do - attrs = remove_empty_servers(config_params) + def handle_event("submit", %{"account" => attrs}, socket) do + attrs = remove_empty_servers(attrs) - with {:ok, new_config} <- - Domain.Config.update_config(socket.assigns.config, attrs, socket.assigns.subject) do + with {:ok, account} <- + Accounts.update_account(socket.assigns.account, attrs, socket.assigns.subject) do form = - Config.change_account_config(new_config, %{}) - |> add_new_server() + Accounts.change_account(account, %{}) + |> maybe_append_empty_embed() |> to_form() - socket = assign(socket, config: new_config, form: form) - {:noreply, socket} + {:noreply, assign(socket, account: account, form: form)} else {:error, changeset} -> - form = to_form(changeset) - socket = assign(socket, form: form) - {:noreply, socket} + changeset = + changeset + |> maybe_append_empty_embed() + |> filter_errors() + |> Map.put(:action, :validate) + + {:noreply, assign(socket, form: to_form(changeset))} end end - defp remove_errors(changeset, field, message) do - errors = - Enum.filter(changeset.errors, fn - {^field, {^message, _}} -> false - {_, _} -> true - end) - - %{changeset | errors: errors} - end - - defp filter_errors(%{changes: %{clients_upstream_dns: clients_upstream_dns}} = changeset) do - filtered_cs = - changeset - |> remove_errors(:clients_upstream_dns, "address can't be blank") - - filtered_dns_cs = - clients_upstream_dns - |> Enum.map(fn changeset -> - remove_errors(changeset, :address, "can't be blank") - end) - - %{filtered_cs | changes: %{clients_upstream_dns: filtered_dns_cs}} - end - defp filter_errors(changeset) do - changeset + update_clients_upstream_dns(changeset, fn + clients_upstream_dns_changesets -> + remove_errors(clients_upstream_dns_changesets, :address, "can't be blank") + end) end - defp remove_empty_servers(config) do - servers = - config["clients_upstream_dns"] - |> Enum.reduce(%{}, fn {key, value}, acc -> - case value["address"] do - nil -> acc - "" -> acc - _ -> Map.put(acc, key, value) + defp remove_errors(changesets, field, message) do + Enum.map(changesets, fn changeset -> + errors = + Enum.filter(changeset.errors, fn + {^field, {^message, _}} -> false + {_, _} -> true + end) + + %{changeset | errors: errors} + end) + end + + defp maybe_append_empty_embed(changeset) do + update_clients_upstream_dns(changeset, fn + clients_upstream_dns_changesets -> + last_client_upstream_dns_changeset = List.last(clients_upstream_dns_changesets) + + with true <- last_client_upstream_dns_changeset != nil, + {_data_or_changes, last_address} <- + Ecto.Changeset.fetch_field(last_client_upstream_dns_changeset, :address), + true <- last_address in [nil, ""] do + clients_upstream_dns_changesets + else + _other -> clients_upstream_dns_changesets ++ [%Accounts.Config.ClientsUpstreamDNS{}] end - end) - - %{"clients_upstream_dns" => servers} + end) end - defp add_new_server(changeset) do - existing_servers = Ecto.Changeset.get_embed(changeset, :clients_upstream_dns) + defp update_clients_upstream_dns(changeset, cb) do + config_changeset = Ecto.Changeset.get_embed(changeset, :config) - Ecto.Changeset.put_embed( - changeset, - :clients_upstream_dns, - existing_servers ++ [%{address: ""}] - ) + clients_upstream_dns_changeset = + Ecto.Changeset.get_embed(config_changeset, :clients_upstream_dns) + + config_changeset = + Ecto.Changeset.put_embed( + config_changeset, + :clients_upstream_dns, + cb.(clients_upstream_dns_changeset) + ) + + Ecto.Changeset.put_embed(changeset, :config, config_changeset) + end + + defp remove_empty_servers(attrs) do + update_in(attrs, [Access.key("config", %{}), "clients_upstream_dns"], fn + nil -> + nil + + servers -> + Map.filter(servers, fn + {_index, %{"address" => ""}} -> false + {_index, %{"address" => nil}} -> false + _ -> true + end) + end) end defp dns_options do @@ -178,10 +205,10 @@ defmodule Web.Settings.DNS do [key: "DNS over HTTPS", value: "dns_over_https"] ] - supported = Enum.map(ClientsUpstreamDNS.supported_protocols(), &to_string/1) + supported_dns_protocols = Enum.map(Accounts.Config.supported_dns_protocols(), &to_string/1) Enum.map(options, fn option -> - case option[:value] in supported do + case option[:value] in supported_dns_protocols do true -> option false -> option ++ [disabled: true] end diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/new.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/new.ex index b76f17ad0..50756aa1b 100644 --- a/elixir/apps/web/lib/web/live/settings/identity_providers/new.ex +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/new.ex @@ -3,8 +3,15 @@ defmodule Web.Settings.IdentityProviders.New do alias Domain.Auth def mount(_params, _session, socket) do - {:ok, adapters} = Auth.list_provider_adapters() - socket = assign(socket, form: %{}, adapters: adapters, page_title: "New Identity Provider") + adapters = Auth.list_user_provisioned_provider_adapters!(socket.assigns.account) + + socket = + assign(socket, + form: %{}, + adapters: adapters, + page_title: "New Identity Provider" + ) + {:ok, socket} end @@ -34,7 +41,12 @@ defmodule Web.Settings.IdentityProviders.New do
Identity Provider Type - <.adapter :for={{adapter, _module} <- @adapters} adapter={adapter} account={@account} /> + <.adapter + :for={{adapter, opts} <- @adapters} + adapter={adapter} + opts={opts} + account={@account} + />
<.submit_button> @@ -52,7 +64,7 @@ defmodule Web.Settings.IdentityProviders.New do <.adapter_item adapter={@adapter} account={@account} - enterprise_feature={true} + opts={@opts} name="Google Workspace" description="Authenticate users and synchronize users and groups with a custom Google Workspace connector." /> @@ -64,7 +76,7 @@ defmodule Web.Settings.IdentityProviders.New do <.adapter_item adapter={@adapter} account={@account} - enterprise_feature={true} + opts={@opts} name="Microsoft Entra" description="Authenticate users and synchronize users and groups with a custom Microsoft Entra connector." /> @@ -76,7 +88,7 @@ defmodule Web.Settings.IdentityProviders.New do <.adapter_item adapter={@adapter} account={@account} - enterprise_feature={true} + opts={@opts} name="Okta" description="Authenticate users and synchronize users and groups with a custom Okta connector." /> @@ -88,26 +100,16 @@ defmodule Web.Settings.IdentityProviders.New do <.adapter_item adapter={@adapter} account={@account} + opts={@opts} name="OpenID Connect" description="Authenticate users with a universal OpenID Connect adapter and manager users and groups manually." /> """ end - def adapter(%{adapter: :saml} = assigns) do - ~H""" - <.adapter_item - adapter={@adapter} - account={@account} - name="SAML 2.0" - description="Authenticate users with a custom SAML 2.0 adapter and synchronize users and groups with SCIM 2.0." - /> - """ - end - attr :adapter, :any attr :account, :any - attr :enterprise_feature, :boolean, default: false + attr :opts, :any attr :name, :string attr :description, :string @@ -120,17 +122,24 @@ defmodule Web.Settings.IdentityProviders.New do type="radio" name="next" value={next_step_path(@adapter, @account)} - class={~w[ w-4 h-4 border-neutral-300 ]} + class={[ + "w-4 h-4 border-neutral-300", + @opts[:enabled] == false && "cursor-not-allowed" + ]} + disabled={@opts[:enabled] == false} required /> <.provider_icon adapter={@adapter} class="w-8 h-8 ml-4" /> - <%= if @enterprise_feature do %> - <.badge class="ml-2" type="primary" title="Feature available on the Enterprise plan"> - ENTERPRISE - + + <%= if @opts[:enabled] == false do %> + <.link navigate={~p"/#{@account}/settings/billing"} class="ml-2 text-sm text-primary-500"> + <.badge class="ml-2" type="primary" title="Feature available on a higher pricing plan"> + UPGRADE TO UNLOCK + + <% end %>

diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/system/show.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/system/show.ex index d80d851a4..086ec7b1c 100644 --- a/elixir/apps/web/lib/web/live/settings/identity_providers/system/show.ex +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/system/show.ex @@ -86,17 +86,6 @@ defmodule Web.Settings.IdentityProviders.System.Show do - - <.danger_zone :if={is_nil(@provider.deleted_at)}> - <:action> - <.delete_button - data-confirm="Are you sure want to delete this provider along with all related data?" - phx-click="delete" - > - Delete Identity Provider - - - """ end diff --git a/elixir/apps/web/lib/web/live/sign_in.ex b/elixir/apps/web/lib/web/live/sign_in.ex index b6c07f2b3..2c08f910c 100644 --- a/elixir/apps/web/lib/web/live/sign_in.ex +++ b/elixir/apps/web/lib/web/live/sign_in.ex @@ -58,7 +58,11 @@ defmodule Web.SignIn do <.flash flash={@flash} kind={:error} /> <.flash flash={@flash} kind={:info} /> - <.intersperse_blocks> + <.flash :if={not Accounts.account_active?(@account)} kind={:error} style="wide"> + This account has been disabled, please contact your administrator. + + + <.intersperse_blocks :if={not disabled?(@account, @params)}> <:separator> <.separator /> @@ -115,6 +119,14 @@ defmodule Web.SignIn do """ end + def disabled?(account, params) do + # We allow to sign in to Web UI even for disabled accounts + case Web.Auth.fetch_auth_context_type!(params) do + :client -> not Accounts.account_active?(account) + :browser -> false + end + end + def separator(assigns) do ~H"""

diff --git a/elixir/apps/web/lib/web/live/sign_up.ex b/elixir/apps/web/lib/web/live/sign_up.ex index 43a5de23f..08e8cf6a4 100644 --- a/elixir/apps/web/lib/web/live/sign_up.ex +++ b/elixir/apps/web/lib/web/live/sign_up.ex @@ -149,7 +149,7 @@ defmodule Web.SignUp do
-
+
<.form for={%{}} id="resend-email" @@ -163,9 +163,10 @@ defmodule Web.SignUp do name="email[provider_identifier]" value={@identity.provider_identifier} /> - <.submit_button> + + <.button type="submit" class="w-full"> Sign In - +
@@ -333,6 +334,8 @@ defmodule Web.SignUp do case Domain.Repo.transaction(multi) do {:ok, %{account: account, provider: provider, identity: identity}} -> + {:ok, account} = Domain.Billing.provision_account(account) + {:ok, _} = Web.Mailer.AuthEmail.sign_up_link_email( account, diff --git a/elixir/apps/web/lib/web/live/sites/new.ex b/elixir/apps/web/lib/web/live/sites/new.ex index b8c7da44f..480cfbe8f 100644 --- a/elixir/apps/web/lib/web/live/sites/new.ex +++ b/elixir/apps/web/lib/web/live/sites/new.ex @@ -21,6 +21,7 @@ defmodule Web.Sites.New do Add a new Site <:content> + <.flash kind={:error} flash={@flash} />
<.form for={@form} phx-change={:change} phx-submit={:submit}> <.input type="hidden" field={@form[:routing]} value="managed" /> @@ -52,6 +53,21 @@ defmodule Web.Sites.New do Gateways.create_group(attrs, socket.assigns.subject) do {:noreply, push_navigate(socket, to: ~p"/#{socket.assigns.account}/sites/#{group}")} else + {:error, :gateway_groups_limit_reached} -> + changeset = + Gateways.new_group(attrs) + |> Map.put(:action, :insert) + + socket = + socket + |> put_flash( + :error, + "You have reached the maximum number of sites allowed by your subscription plan." + ) + |> assign(form: to_form(changeset)) + + {:noreply, socket} + {:error, changeset} -> {:noreply, assign(socket, form: to_form(changeset))} end diff --git a/elixir/apps/web/lib/web/router.ex b/elixir/apps/web/lib/web/router.ex index a50461609..44555a132 100644 --- a/elixir/apps/web/lib/web/router.ex +++ b/elixir/apps/web/lib/web/router.ex @@ -10,12 +10,6 @@ defmodule Web.Router do plug :put_root_layout, {Web.Layouts, :root} end - pipeline :api do - plug :accepts, ["json"] - plug :ensure_authenticated - plug :ensure_authenticated_actor_type, :service_account - end - pipeline :public do plug :accepts, ["html", "xml"] end @@ -136,8 +130,6 @@ defmodule Web.Router do end live "/:id/edit", Edit - # TODO: REMOVEME it's just another identity - live "/:id/new_token", NewToken end scope "/groups", Groups do @@ -204,6 +196,7 @@ defmodule Web.Router do scope "/settings", Settings do live "/account", Account + live "/billing", Billing scope "/identity_providers", IdentityProviders do live "/", Index diff --git a/elixir/apps/web/priv/static/.well-known/apple-developer-merchantid-domain-association b/elixir/apps/web/priv/static/.well-known/apple-developer-merchantid-domain-association new file mode 100644 index 000000000..2ff95c962 --- /dev/null +++ b/elixir/apps/web/priv/static/.well-known/apple-developer-merchantid-domain-association @@ -0,0 +1 @@ +7B227073704964223A2239373943394538343346343131343044463144313834343232393232313734313034353044314339464446394437384337313531303944334643463542433731222C2276657273696F6E223A312C22637265617465644F6E223A313536363233343735303036312C227369676E6174757265223A22333038303036303932613836343838366637306430313037303261303830333038303032303130313331306633303064303630393630383634383031363530333034303230313035303033303830303630393261383634383836663730643031303730313030303061303830333038323033653333303832303338386130303330323031303230323038346333303431343935313964353433363330306130363038326138363438636533643034303330323330376133313265333032633036303335353034303330633235343137303730366336353230343137303730366336393633363137343639366636653230343936653734363536373732363137343639366636653230343334313230326432303437333333313236333032343036303335353034306230633164343137303730366336353230343336353732373436393636363936333631373436393666366532303431373537343638366637323639373437393331313333303131303630333535303430613063306134313730373036633635323034393665363332653331306233303039303630333535303430363133303235353533333031653137306433313339333033353331333833303331333333323335333735613137306433323334333033353331333633303331333333323335333735613330356633313235333032333036303335353034303330633163363536333633326437333664373032643632373236663662363537323264373336393637366535663535343333343264353035323466343433313134333031323036303335353034306230633062363934663533323035333739373337343635366437333331313333303131303630333535303430613063306134313730373036633635323034393665363332653331306233303039303630333535303430363133303235353533333035393330313330363037326138363438636533643032303130363038326138363438636533643033303130373033343230303034633231353737656465626436633762323231386636386464373039306131323138646337623062643666326332383364383436303935643934616634613534313162383334323065643831316633343037653833333331663163353463336637656233323230643662616435643465666634393238393839336537633066313361333832303231313330383230323064333030633036303335353164313330313031666630343032333030303330316630363033353531643233303431383330313638303134323366323439633434663933653465663237653663346636323836633366613262626664326534623330343530363038326230363031303530353037303130313034333933303337333033353036303832623036303130353035303733303031383632393638373437343730336132663266366636333733373032653631373037303663363532653633366636643266366636333733373033303334326436313730373036633635363136393633363133333330333233303832303131643036303335353164323030343832303131343330383230313130333038323031306330363039326138363438383666373633363430353031333038316665333038316333303630383262303630313035303530373032303233303831623630633831623335323635366336393631366536333635323036663665323037343638363937333230363336353732373436393636363936333631373436353230363237393230363136653739323037303631373237343739323036313733373337353664363537333230363136333633363537303734363136653633363532303666363632303734363836353230373436383635366532303631373037303663363936333631363236633635323037333734363136653634363137323634323037343635373236643733323036313665363432303633366636653634363937343639366636653733323036663636323037353733363532633230363336353732373436393636363936333631373436353230373036663663363936333739323036313665363432303633363537323734363936363639363336313734363936663665323037303732363136333734363936333635323037333734363137343635366436353665373437333265333033363036303832623036303130353035303730323031313632613638373437343730336132663266373737373737326536313730373036633635326536333666366432663633363537323734363936363639363336313734363536313735373436383666373236393734373932663330333430363033353531643166303432643330326233303239613032376130323538363233363837343734373033613266326636333732366332653631373037303663363532653633366636643266363137303730366336353631363936333631333332653633373236633330316430363033353531643065303431363034313439343537646236666435373438313836383938393736326637653537383530376537396235383234333030653036303335353164306630313031666630343034303330323037383033303066303630393261383634383836663736333634303631643034303230353030333030613036303832613836343863653364303430333032303334393030333034363032323130306265303935373166653731653165373335623535653561666163623463373266656234343566333031383532323263373235313030326236316562643666353530323231303064313862333530613564643664643665623137343630333562313165623263653837636661336536616636636264383338303839306463383263646461613633333038323032656533303832303237356130303330323031303230323038343936643266626633613938646139373330306130363038326138363438636533643034303330323330363733313162333031393036303335353034303330633132343137303730366336353230353236663666373432303433343132303264323034373333333132363330323430363033353530343062306331643431373037303663363532303433363537323734363936363639363336313734363936663665323034313735373436383666373236393734373933313133333031313036303335353034306130633061343137303730366336353230343936653633326533313062333030393036303335353034303631333032353535333330316531373064333133343330333533303336333233333334333633333330356131373064333233393330333533303336333233333334333633333330356133303761333132653330326330363033353530343033306332353431373037303663363532303431373037303663363936333631373436393666366532303439366537343635363737323631373436393666366532303433343132303264323034373333333132363330323430363033353530343062306331643431373037303663363532303433363537323734363936363639363336313734363936663665323034313735373436383666373236393734373933313133333031313036303335353034306130633061343137303730366336353230343936653633326533313062333030393036303335353034303631333032353535333330353933303133303630373261383634386365336430323031303630383261383634386365336430333031303730333432303030346630313731313834313964373634383564353161356532353831303737366538383061326566646537626165346465303864666334623933653133333536643536363562333561653232643039373736306432323465376262613038666437363137636538386362373662623636373062656338653832393834666635343435613338316637333038316634333034363036303832623036303130353035303730313031303433613330333833303336303630383262303630313035303530373330303138363261363837343734373033613266326636663633373337303265363137303730366336353265363336663664326636663633373337303330333432643631373037303663363537323666366637343633363136373333333031643036303335353164306530343136303431343233663234396334346639336534656632376536633466363238366333666132626266643265346233303066303630333535316431333031303166663034303533303033303130316666333031663036303335353164323330343138333031363830313462626230646561313538333338383961613438613939646562656264656261666461636232346162333033373036303335353164316630343330333032653330326361303261613032383836323636383734373437303361326632663633373236633265363137303730366336353265363336663664326636313730373036633635373236663666373436333631363733333265363337323663333030653036303335353164306630313031666630343034303330323031303633303130303630613261383634383836663736333634303630323065303430323035303033303061303630383261383634386365336430343033303230333637303033303634303233303361636637323833353131363939623138366662333563333536636136326266663431376564643930663735346461323865626566313963383135653432623738396638393866373962353939663938643534313064386639646539633266653032333033323264643534343231623061333035373736633564663333383362393036376664313737633263323136643936346663363732363938323132366635346638376137643162393963623962303938393231363130363939306630393932316430303030333138323031386233303832303138373032303130313330383138363330376133313265333032633036303335353034303330633235343137303730366336353230343137303730366336393633363137343639366636653230343936653734363536373732363137343639366636653230343334313230326432303437333333313236333032343036303335353034306230633164343137303730366336353230343336353732373436393636363936333631373436393666366532303431373537343638366637323639373437393331313333303131303630333535303430613063306134313730373036633635323034393665363332653331306233303039303630333535303430363133303235353533303230383463333034313439353139643534333633303064303630393630383634383031363530333034303230313035303061303831393533303138303630393261383634383836663730643031303930333331306230363039326138363438383666373064303130373031333031633036303932613836343838366637306430313039303533313066313730643331333933303338333133393331333733313332333333303561333032613036303932613836343838366637306430313039333433313164333031623330306430363039363038363438303136353033303430323031303530306131306130363038326138363438636533643034303330323330326630363039326138363438383666373064303130393034333132323034323062303731303365313430613462386231376262613230316130336163643036396234653431366232613263383066383661383338313435633239373566633131333030613036303832613836343863653364303430333032303434363330343430323230343639306264636637626461663833636466343934396534633035313039656463663334373665303564373261313264376335666538633033303033343464663032323032363764353863393365626233353031333836363062353730373938613064643731313734316262353864626436613138363633353038353431656565393035303030303030303030303030227D \ No newline at end of file diff --git a/elixir/apps/web/test/web/live/actors/service_accounts/new_test.exs b/elixir/apps/web/test/web/live/actors/service_accounts/new_test.exs index 21f270f0b..0bacab59e 100644 --- a/elixir/apps/web/test/web/live/actors/service_accounts/new_test.exs +++ b/elixir/apps/web/test/web/live/actors/service_accounts/new_test.exs @@ -118,6 +118,38 @@ defmodule Web.Live.Actors.ServiceAccount.NewTest do } end + test "renders changeset errors when seats count is exceeded", %{ + account: account, + identity: identity, + conn: conn + } do + {:ok, account} = + Domain.Accounts.update_account(account, %{ + limits: %{ + service_accounts_count: 1 + } + }) + + Fixtures.Actors.create_actor(type: :service_account, account: account) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/actors/service_accounts/new") + + attrs = + Fixtures.Actors.actor_attrs() + |> Map.take([:name]) + + html = + lv + |> form("form", actor: attrs) + |> render_submit() + + assert html =~ "You have reached the maximum number of" + assert html =~ "service accounts allowed by your subscription plan" + end + test "creates a new actor on valid attrs", %{ account: account, actor: actor, diff --git a/elixir/apps/web/test/web/live/actors/users/new_test.exs b/elixir/apps/web/test/web/live/actors/users/new_test.exs index 77b0ea4ee..5df1ba152 100644 --- a/elixir/apps/web/test/web/live/actors/users/new_test.exs +++ b/elixir/apps/web/test/web/live/actors/users/new_test.exs @@ -120,6 +120,39 @@ defmodule Web.Live.Actors.User.NewTest do } end + test "renders changeset errors when seats count is exceeded", %{ + account: account, + identity: identity, + conn: conn + } do + {:ok, account} = + Domain.Accounts.update_account(account, %{ + limits: %{ + monthly_active_users_count: 1 + } + }) + + actor = Fixtures.Actors.create_actor(type: :account_user, account: account) + Fixtures.Clients.create_client(account: account, actor: actor) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/actors/users/new") + + attrs = + Fixtures.Actors.actor_attrs() + |> Map.take([:name]) + + html = + lv + |> form("form", actor: attrs) + |> render_submit() + + assert html =~ "You have reached the maximum number of" + assert html =~ "seats allowed by your subscription plan." + end + test "creates a new actor on valid attrs", %{ account: account, actor: actor, diff --git a/elixir/apps/web/test/web/live/settings/account/index_test.exs b/elixir/apps/web/test/web/live/settings/account/index_test.exs deleted file mode 100644 index 547c60ce7..000000000 --- a/elixir/apps/web/test/web/live/settings/account/index_test.exs +++ /dev/null @@ -1,63 +0,0 @@ -defmodule Web.Live.Settings.Account.IndexTest do - use Web.ConnCase, async: true - - setup do - Domain.Config.put_env_override(:outbound_email_adapter_configured?, true) - - account = Fixtures.Accounts.create_account() - identity = Fixtures.Auth.create_identity(account: account, actor: [type: :account_admin_user]) - - %{ - account: account, - identity: identity - } - end - - test "redirects to sign in page for unauthorized user", %{account: account, conn: conn} do - path = ~p"/#{account}/settings/account" - - assert live(conn, path) == - {:error, - {:redirect, - %{ - to: ~p"/#{account}?#{%{redirect_to: path}}", - flash: %{"error" => "You must sign in to access this page."} - }}} - end - - test "renders breadcrumbs item", %{ - account: account, - identity: identity, - conn: conn - } do - {:ok, _lv, html} = - conn - |> authorize_conn(identity) - |> live(~p"/#{account}/settings/account") - - assert item = Floki.find(html, "[aria-label='Breadcrumb']") - breadcrumbs = String.trim(Floki.text(item)) - assert breadcrumbs =~ "Account Settings" - end - - test "renders table with account information", %{ - account: account, - identity: identity, - conn: conn - } do - {:ok, lv, _html} = - conn - |> authorize_conn(identity) - |> live(~p"/#{account}/settings/account") - - rows = - lv - |> element("#account") - |> render() - |> vertical_table_to_map() - - assert rows["account name"] == account.name - assert rows["account id"] == account.id - assert rows["account slug"] =~ account.slug - end -end diff --git a/elixir/apps/web/test/web/live/settings/account_test.exs b/elixir/apps/web/test/web/live/settings/account_test.exs new file mode 100644 index 000000000..fcfc2628d --- /dev/null +++ b/elixir/apps/web/test/web/live/settings/account_test.exs @@ -0,0 +1,124 @@ +defmodule Web.Live.Settings.AccountTest do + use Web.ConnCase, async: true + + setup do + Domain.Config.put_env_override(:outbound_email_adapter_configured?, true) + + account = + Fixtures.Accounts.create_account( + metadata: %{ + stripe: %{ + customer_id: "cus_NffrFeUfNV2Hib", + subscription_id: "sub_NffrFeUfNV2Hib", + product_name: "Enterprise" + } + }, + limits: %{ + monthly_active_users_count: 100 + } + ) + + identity = Fixtures.Auth.create_identity(account: account, actor: [type: :account_admin_user]) + + %{ + account: account, + identity: identity + } + end + + test "redirects to sign in page for unauthorized user", %{account: account, conn: conn} do + path = ~p"/#{account}/settings/account" + + assert live(conn, path) == + {:error, + {:redirect, + %{ + to: ~p"/#{account}?#{%{redirect_to: path}}", + flash: %{"error" => "You must sign in to access this page."} + }}} + end + + test "renders breadcrumbs item", %{ + account: account, + identity: identity, + conn: conn + } do + {:ok, _lv, html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/account") + + assert item = Floki.find(html, "[aria-label='Breadcrumb']") + breadcrumbs = String.trim(Floki.text(item)) + assert breadcrumbs =~ "Account Settings" + end + + test "renders table with account information even if billing portal is down", %{ + account: account, + identity: identity, + conn: conn + } do + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/account") + + rows = + lv + |> element("#account") + |> render() + |> vertical_table_to_map() + + assert rows["account name"] == account.name + assert rows["account id"] == account.id + assert rows["account slug"] =~ account.slug + end + + test "renders error when limit is exceeded", %{ + account: account, + identity: identity, + conn: conn + } do + account = + Fixtures.Accounts.update_account(account, %{ + warning: "You have reached your monthly active actors limit." + }) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/account") + + html = lv |> render() + assert html =~ "You have reached your monthly active actors limit." + assert html =~ "check your billing information" + end + + test "renders error when account is disabled", %{ + account: account, + identity: identity, + conn: conn + } do + account = + Fixtures.Accounts.update_account(account, %{ + disabled_at: DateTime.utc_now() + }) + + actor = Fixtures.Actors.create_actor(account: account, type: :account_admin_user) + + Fixtures.Clients.create_client( + account: account, + actor: actor, + last_seen_at: DateTime.utc_now() + ) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/account") + + html = lv |> render() + assert html =~ "This account has been disabled." + assert html =~ "contact support" + end +end diff --git a/elixir/apps/web/test/web/live/settings/billing_test.exs b/elixir/apps/web/test/web/live/settings/billing_test.exs new file mode 100644 index 000000000..9b70bda9d --- /dev/null +++ b/elixir/apps/web/test/web/live/settings/billing_test.exs @@ -0,0 +1,153 @@ +defmodule Web.Live.Settings.BillingTest do + use Web.ConnCase, async: true + + setup do + Domain.Config.put_env_override(:outbound_email_adapter_configured?, true) + + account = + Fixtures.Accounts.create_account( + metadata: %{ + stripe: %{ + customer_id: "cus_NffrFeUfNV2Hib", + subscription_id: "sub_NffrFeUfNV2Hib", + product_name: "Enterprise" + } + }, + limits: %{ + monthly_active_users_count: 100, + service_accounts_count: 100, + gateway_groups_count: 10, + account_admin_users_count: 2 + } + ) + + identity = Fixtures.Auth.create_identity(account: account, actor: [type: :account_admin_user]) + + %{ + account: account, + identity: identity + } + end + + test "redirects to sign in page for unauthorized user", %{account: account, conn: conn} do + path = ~p"/#{account}/settings/billing" + + assert live(conn, path) == + {:error, + {:redirect, + %{ + to: ~p"/#{account}?#{%{redirect_to: path}}", + flash: %{"error" => "You must sign in to access this page."} + }}} + end + + test "renders breadcrumbs item", %{ + account: account, + identity: identity, + conn: conn + } do + {:ok, _lv, html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/billing") + + assert item = Floki.find(html, "[aria-label='Breadcrumb']") + breadcrumbs = String.trim(Floki.text(item)) + assert breadcrumbs =~ "Billing" + end + + test "renders table with account information even if billing portal is down", %{ + account: account, + identity: identity, + conn: conn + } do + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/billing") + + rows = + lv + |> element("#billing") + |> render() + |> vertical_table_to_map() + + assert rows["current plan"] =~ account.metadata.stripe.product_name + assert rows["seats"] =~ "0 used / 100 allowed" + assert rows["sites"] =~ "0 used / 10 allowed" + assert rows["admins"] =~ "1 used / 2 allowed" + + html = element(lv, "button[phx-click='redirect_to_billing_portal']") |> render_click() + assert html =~ "Billing portal is temporarily unavailable, please try again later." + end + + test "renders billing portal button", %{ + account: account, + identity: identity, + conn: conn + } do + Bypass.open() + |> Mocks.Stripe.mock_create_billing_session_endpoint(account) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/billing") + + assert has_element?(lv, "button[phx-click='redirect_to_billing_portal']") + + assert {:error, {:redirect, %{to: to}}} = + element(lv, "button[phx-click='redirect_to_billing_portal']") + |> render_click() + + assert to =~ "https://billing.stripe.com/p/session" + end + + test "renders error when limit is exceeded", %{ + account: account, + identity: identity, + conn: conn + } do + account = + Fixtures.Accounts.update_account(account, %{ + warning: "You have reached your monthly active actors limit." + }) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/billing") + + html = lv |> render() + assert html =~ "You have reached your monthly active actors limit." + assert html =~ "check your billing information" + end + + test "renders error when account is disabled", %{ + account: account, + identity: identity, + conn: conn + } do + account = + Fixtures.Accounts.update_account(account, %{ + disabled_at: DateTime.utc_now() + }) + + actor = Fixtures.Actors.create_actor(account: account, type: :account_admin_user) + + Fixtures.Clients.create_client( + account: account, + actor: actor, + last_seen_at: DateTime.utc_now() + ) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/billing") + + html = lv |> render() + assert html =~ "This account has been disabled." + assert html =~ "contact support" + end +end diff --git a/elixir/apps/web/test/web/live/settings/dns/index_test.exs b/elixir/apps/web/test/web/live/settings/dns_test.exs similarity index 51% rename from elixir/apps/web/test/web/live/settings/dns/index_test.exs rename to elixir/apps/web/test/web/live/settings/dns_test.exs index b880456bd..7dc909585 100644 --- a/elixir/apps/web/test/web/live/settings/dns/index_test.exs +++ b/elixir/apps/web/test/web/live/settings/dns_test.exs @@ -1,4 +1,4 @@ -defmodule Web.Live.Settings.DNS.IndexTest do +defmodule Web.Live.Settings.DNSTest do use Web.ConnCase, async: true setup do @@ -45,6 +45,8 @@ defmodule Web.Live.Settings.DNS.IndexTest do identity: identity, conn: conn } do + Fixtures.Accounts.update_account(account, %{config: %{clients_upstream_dns: []}}) + {:ok, lv, _html} = conn |> authorize_conn(identity) @@ -53,9 +55,10 @@ defmodule Web.Live.Settings.DNS.IndexTest do form = lv |> form("form") assert find_inputs(form) == [ - "configuration[clients_upstream_dns][0][_persistent_id]", - "configuration[clients_upstream_dns][0][address]", - "configuration[clients_upstream_dns][0][protocol]" + "account[config][_persistent_id]", + "account[config][clients_upstream_dns][0][_persistent_id]", + "account[config][clients_upstream_dns][0][address]", + "account[config][clients_upstream_dns][0][protocol]" ] end @@ -64,7 +67,15 @@ defmodule Web.Live.Settings.DNS.IndexTest do identity: identity, conn: conn } do - attrs = %{configuration: %{clients_upstream_dns: %{"0" => %{address: "8.8.8.8"}}}} + Fixtures.Accounts.update_account(account, %{config: %{clients_upstream_dns: []}}) + + attrs = %{ + account: %{ + config: %{ + clients_upstream_dns: %{"0" => %{address: "8.8.8.8"}} + } + } + } {:ok, lv, _html} = conn @@ -78,12 +89,13 @@ defmodule Web.Live.Settings.DNS.IndexTest do assert lv |> form("form") |> find_inputs() == [ - "configuration[clients_upstream_dns][0][_persistent_id]", - "configuration[clients_upstream_dns][0][address]", - "configuration[clients_upstream_dns][0][protocol]", - "configuration[clients_upstream_dns][1][_persistent_id]", - "configuration[clients_upstream_dns][1][address]", - "configuration[clients_upstream_dns][1][protocol]" + "account[config][_persistent_id]", + "account[config][clients_upstream_dns][0][_persistent_id]", + "account[config][clients_upstream_dns][0][address]", + "account[config][clients_upstream_dns][0][protocol]", + "account[config][clients_upstream_dns][1][_persistent_id]", + "account[config][clients_upstream_dns][1][address]", + "account[config][clients_upstream_dns][1][protocol]" ] end @@ -93,8 +105,12 @@ defmodule Web.Live.Settings.DNS.IndexTest do conn: conn } do attrs = %{ - configuration: %{ - clients_upstream_dns: %{"0" => %{address: "8.8.8.8"}} + account: %{ + config: %{ + clients_upstream_dns: %{ + "0" => %{address: ""} + } + } } } @@ -110,28 +126,16 @@ defmodule Web.Live.Settings.DNS.IndexTest do assert lv |> form("form") |> find_inputs() == [ - "configuration[clients_upstream_dns][0][_persistent_id]", - "configuration[clients_upstream_dns][0][address]", - "configuration[clients_upstream_dns][0][protocol]", - "configuration[clients_upstream_dns][1][_persistent_id]", - "configuration[clients_upstream_dns][1][address]", - "configuration[clients_upstream_dns][1][protocol]" - ] - - empty_attrs = %{ - configuration: %{ - clients_upstream_dns: %{"0" => %{address: ""}} - } - } - - lv |> form("form", empty_attrs) |> render_submit() - - assert lv - |> form("form") - |> find_inputs() == [ - "configuration[clients_upstream_dns][0][_persistent_id]", - "configuration[clients_upstream_dns][0][address]", - "configuration[clients_upstream_dns][0][protocol]" + "account[config][_persistent_id]", + "account[config][clients_upstream_dns][0][_persistent_id]", + "account[config][clients_upstream_dns][0][address]", + "account[config][clients_upstream_dns][0][protocol]", + "account[config][clients_upstream_dns][1][_persistent_id]", + "account[config][clients_upstream_dns][1][address]", + "account[config][clients_upstream_dns][1][protocol]", + "account[config][clients_upstream_dns][2][_persistent_id]", + "account[config][clients_upstream_dns][2][address]", + "account[config][clients_upstream_dns][2][protocol]" ] end @@ -145,8 +149,10 @@ defmodule Web.Live.Settings.DNS.IndexTest do addr2 = %{address: "1.1.1.1"} attrs = %{ - configuration: %{ - clients_upstream_dns: %{"0" => addr1} + account: %{ + config: %{ + clients_upstream_dns: %{"0" => addr1} + } } } @@ -160,16 +166,28 @@ defmodule Web.Live.Settings.DNS.IndexTest do |> render_submit() assert lv - |> form("form", %{configuration: %{clients_upstream_dns: %{"1" => addr1}}}) - |> render_change() =~ "no duplicates allowed" + |> form("form", %{ + account: %{ + config: %{clients_upstream_dns: %{"1" => addr1}} + } + }) + |> render_change() =~ "all addresses must be unique" refute lv - |> form("form", %{configuration: %{clients_upstream_dns: %{"1" => addr2}}}) - |> render_change() =~ "no duplicates allowed" + |> form("form", %{ + account: %{ + config: %{clients_upstream_dns: %{"1" => addr2}} + } + }) + |> render_change() =~ "all addresses must be unique" assert lv - |> form("form", %{configuration: %{clients_upstream_dns: %{"1" => addr1_dup}}}) - |> render_change() =~ "no duplicates allowed" + |> form("form", %{ + account: %{ + config: %{clients_upstream_dns: %{"1" => addr1_dup}} + } + }) + |> render_change() =~ "all addresses must be unique" end test "does not display 'cannot be empty' error message", %{ @@ -178,8 +196,10 @@ defmodule Web.Live.Settings.DNS.IndexTest do conn: conn } do attrs = %{ - configuration: %{ - clients_upstream_dns: %{"0" => %{address: "8.8.8.8"}} + account: %{ + config: %{ + clients_upstream_dns: %{"0" => %{address: "8.8.8.8"}} + } } } @@ -193,7 +213,13 @@ defmodule Web.Live.Settings.DNS.IndexTest do |> render_submit() refute lv - |> form("form", %{configuration: %{clients_upstream_dns: %{"0" => %{address: ""}}}}) + |> form("form", %{ + account: %{ + config: %{ + clients_upstream_dns: %{"0" => %{address: ""}} + } + } + }) |> render_change() =~ "can't be blank" end end diff --git a/elixir/apps/web/test/web/live/settings/identity_providers/new_test.exs b/elixir/apps/web/test/web/live/settings/identity_providers/new_test.exs index 4b6568220..ca24ff5ad 100644 --- a/elixir/apps/web/test/web/live/settings/identity_providers/new_test.exs +++ b/elixir/apps/web/test/web/live/settings/identity_providers/new_test.exs @@ -4,7 +4,7 @@ defmodule Web.Live.Settings.IdentityProviders.NewTest do setup do Domain.Config.put_env_override(:outbound_email_adapter_configured?, true) - account = Fixtures.Accounts.create_account() + account = Fixtures.Accounts.create_account(features: %{idp_sync: false}) actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account) {provider, bypass} = Fixtures.Auth.start_and_create_openid_connect_provider(account: account) @@ -44,8 +44,8 @@ defmodule Web.Live.Settings.IdentityProviders.NewTest do assert has_element?(lv, "#idp-option-google_workspace") assert html =~ "Google Workspace" - assert html =~ "Feature available on the Enterprise plan" - assert html =~ "ENTERPRISE" + assert html =~ "Feature available on a higher pricing plan" + assert html =~ "UPGRADE TO UNLOCK" assert has_element?(lv, "#idp-option-microsoft_entra") assert html =~ "Microsoft Entra" diff --git a/elixir/apps/web/test/web/live/settings/identity_providers/system/show_test.exs b/elixir/apps/web/test/web/live/settings/identity_providers/system/show_test.exs index 0b260710a..bc38afdc5 100644 --- a/elixir/apps/web/test/web/live/settings/identity_providers/system/show_test.exs +++ b/elixir/apps/web/test/web/live/settings/identity_providers/system/show_test.exs @@ -138,7 +138,7 @@ defmodule Web.Live.Settings.IdentityProviders.System.ShowTest do |> Map.fetch!("status") == "Active" end - test "allows deleting identity providers", %{ + test "does not allow deleting system identity providers", %{ account: account, provider: provider, identity: identity, @@ -149,12 +149,6 @@ defmodule Web.Live.Settings.IdentityProviders.System.ShowTest do |> authorize_conn(identity) |> live(~p"/#{account}/settings/identity_providers/system/#{provider}") - lv - |> element("button", "Delete Identity Provider") - |> render_click() - - assert_redirected(lv, ~p"/#{account}/settings/identity_providers") - - assert Repo.get(Domain.Auth.Provider, provider.id).deleted_at + refute has_element?(lv, "button", "Delete Identity Provider") end end diff --git a/elixir/apps/web/test/web/live/sidebar_test.exs b/elixir/apps/web/test/web/live/sidebar_test.exs index e6fc76171..5a027ad23 100644 --- a/elixir/apps/web/test/web/live/sidebar_test.exs +++ b/elixir/apps/web/test/web/live/sidebar_test.exs @@ -18,7 +18,7 @@ defmodule Web.SidebarTest do account: account, identity: identity } do - {:ok, _lv, html} = live(authorize_conn(conn, identity), ~p"/#{account}/actors") + {:ok, _lv, html} = conn |> authorize_conn(identity) |> live(~p"/#{account}/actors") refute Enum.empty?(Floki.find(html, "ul#dropdown-settings.hidden")) end @@ -27,7 +27,7 @@ defmodule Web.SidebarTest do account: account, identity: identity } do - {:ok, _lv, html} = live(authorize_conn(conn, identity), ~p"/#{account}/settings/dns") + {:ok, _lv, html} = conn |> authorize_conn(identity) |> live(~p"/#{account}/settings/dns") assert Enum.empty?(Floki.find(html, "ul#dropdown-settings.hidden")) refute Enum.empty?(Floki.find(html, "ul#dropdown-settings")) end @@ -37,7 +37,7 @@ defmodule Web.SidebarTest do identity: identity, conn: conn } do - {:ok, _lv, html} = live(authorize_conn(conn, identity), ~p"/#{account}/actors") + {:ok, _lv, html} = conn |> authorize_conn(identity) |> live(~p"/#{account}/actors") assert item = Floki.find(html, "a.bg-neutral-100[href='/#{account.slug}/actors']") assert String.trim(Floki.text(item)) == "Actors" end @@ -47,7 +47,7 @@ defmodule Web.SidebarTest do identity: identity, conn: conn } do - {:ok, _lv, html} = live(authorize_conn(conn, identity), ~p"/#{account}/groups") + {:ok, _lv, html} = conn |> authorize_conn(identity) |> live(~p"/#{account}/groups") assert item = Floki.find(html, "a.bg-neutral-100[href='/#{account.slug}/groups']") assert String.trim(Floki.text(item)) == "Groups" end @@ -57,7 +57,7 @@ defmodule Web.SidebarTest do identity: identity, conn: conn } do - {:ok, _lv, html} = live(authorize_conn(conn, identity), ~p"/#{account}/clients") + {:ok, _lv, html} = conn |> authorize_conn(identity) |> live(~p"/#{account}/clients") assert item = Floki.find(html, "a.bg-neutral-100[href='/#{account.slug}/clients']") assert String.trim(Floki.text(item)) == "Clients" end @@ -67,7 +67,7 @@ defmodule Web.SidebarTest do identity: identity, conn: conn } do - {:ok, _lv, html} = live(authorize_conn(conn, identity), ~p"/#{account}/sites") + {:ok, _lv, html} = conn |> authorize_conn(identity) |> live(~p"/#{account}/sites") assert item = Floki.find(html, "a.bg-neutral-100[href='/#{account.slug}/sites']") assert String.trim(Floki.text(item)) == "Sites" end @@ -77,7 +77,7 @@ defmodule Web.SidebarTest do identity: identity, conn: conn } do - {:ok, _lv, html} = live(authorize_conn(conn, identity), ~p"/#{account}/relay_groups") + {:ok, _lv, html} = conn |> authorize_conn(identity) |> live(~p"/#{account}/relay_groups") assert item = Floki.find(html, "a.bg-neutral-100[href='/#{account.slug}/relay_groups']") assert String.trim(Floki.text(item)) == "Relays" end @@ -87,7 +87,7 @@ defmodule Web.SidebarTest do # identity: identity, # conn: conn # } do - # {:ok, _lv, html} = live(authorize_conn(conn, identity), ~p"/#{account}/resources") + # {:ok, _lv, html} = conn |> authorize_conn(identity) |> live( ~p"/#{account}/resources") # assert item = Floki.find(html, "a.bg-neutral-100[href='/#{account.slug}/resources']") # assert String.trim(Floki.text(item)) == "Resources" # end @@ -97,7 +97,7 @@ defmodule Web.SidebarTest do identity: identity, conn: conn } do - {:ok, _lv, html} = live(authorize_conn(conn, identity), ~p"/#{account}/policies") + {:ok, _lv, html} = conn |> authorize_conn(identity) |> live(~p"/#{account}/policies") assert item = Floki.find(html, "a.bg-neutral-100[href='/#{account.slug}/policies']") assert String.trim(Floki.text(item)) == "Policies" end @@ -107,7 +107,7 @@ defmodule Web.SidebarTest do identity: identity, conn: conn } do - {:ok, _lv, html} = live(authorize_conn(conn, identity), ~p"/#{account}/settings/account") + {:ok, _lv, html} = conn |> authorize_conn(identity) |> live(~p"/#{account}/settings/account") assert item = Floki.find(html, "a.bg-neutral-100[href='/#{account.slug}/settings/account']") assert String.trim(Floki.text(item)) == "Account" end @@ -118,7 +118,7 @@ defmodule Web.SidebarTest do conn: conn } do {:ok, _lv, html} = - live(authorize_conn(conn, identity), ~p"/#{account}/settings/identity_providers") + conn |> authorize_conn(identity) |> live(~p"/#{account}/settings/identity_providers") assert item = Floki.find( @@ -154,7 +154,7 @@ defmodule Web.SidebarTest do identity: identity, conn: conn } do - {:ok, _lv, html} = live(authorize_conn(conn, identity), ~p"/#{account}/settings/dns") + {:ok, _lv, html} = conn |> authorize_conn(identity) |> live(~p"/#{account}/settings/dns") assert item = Floki.find(html, "a.bg-neutral-100[href='/#{account.slug}/settings/dns']") assert String.trim(Floki.text(item)) == "DNS" end diff --git a/elixir/apps/web/test/web/live/sign_in_test.exs b/elixir/apps/web/test/web/live/sign_in_test.exs index ae69ad48f..8c44ca954 100644 --- a/elixir/apps/web/test/web/live/sign_in_test.exs +++ b/elixir/apps/web/test/web/live/sign_in_test.exs @@ -68,4 +68,13 @@ defmodule Web.SignInTest do refute html =~ ~s|Meant to sign in from a client instead?| end + + test "renders error when account is disabled", %{conn: conn} do + Domain.Config.put_env_override(:outbound_email_adapter_configured?, true) + account = Fixtures.Accounts.create_account() + {:ok, _account} = Domain.Accounts.update_account(account, %{disabled_at: DateTime.utc_now()}) + Fixtures.Auth.create_email_provider(account: account) + {:ok, _lv, html} = live(conn, ~p"/#{account}") + assert html =~ "This account has been disabled, please contact your administrator." + end end diff --git a/elixir/apps/web/test/web/live/sign_up_test.exs b/elixir/apps/web/test/web/live/sign_up_test.exs index 86befb485..dd989e6b3 100644 --- a/elixir/apps/web/test/web/live/sign_up_test.exs +++ b/elixir/apps/web/test/web/live/sign_up_test.exs @@ -32,6 +32,13 @@ defmodule Web.Live.SignUpTest do email: email } + Bypass.open() + |> Domain.Mocks.Stripe.mock_create_customer_endpoint(%{ + id: Ecto.UUID.generate(), + name: account_name + }) + |> Domain.Mocks.Stripe.mock_create_subscription_endpoint() + assert html = lv |> form("form", registration: attrs) @@ -42,6 +49,7 @@ defmodule Web.Live.SignUpTest do account = Repo.one(Domain.Accounts.Account) assert account.name == account_name + assert account.metadata.stripe.customer_id provider = Repo.one(Domain.Auth.Provider) assert provider.account_id == account.id diff --git a/elixir/apps/web/test/web/live/sites/new_test.exs b/elixir/apps/web/test/web/live/sites/new_test.exs index a5e6b7efe..b95214123 100644 --- a/elixir/apps/web/test/web/live/sites/new_test.exs +++ b/elixir/apps/web/test/web/live/sites/new_test.exs @@ -126,4 +126,36 @@ defmodule Web.Live.Sites.NewTest do assert assert_redirect(lv, ~p"/#{account}/sites/#{group}") end + + test "renders error when sites limit is reached", %{ + account: account, + identity: identity, + conn: conn + } do + Fixtures.Gateways.create_group(account: account) + + {:ok, account} = + Domain.Accounts.update_account(account, %{ + limits: %{ + gateway_groups_count: 1 + } + }) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/sites/new") + + attrs = + Fixtures.Gateways.group_attrs() + |> Map.take([:name]) + + html = + lv + |> form("form", group: attrs) + |> render_submit() + + assert html =~ "You have reached the maximum number of" + assert html =~ "sites allowed by your subscription plan." + end end diff --git a/elixir/config/config.exs b/elixir/config/config.exs index 89c170d5a..1efccddc3 100644 --- a/elixir/config/config.exs +++ b/elixir/config/config.exs @@ -33,8 +33,6 @@ config :domain, Domain.Tokens, key_base: "5OVYJ83AcoQcPmdKNksuBhJFBhjHD1uUa9mDOHV/6EIdBQ6pXksIhkVeWIzFk5S2", salt: "t01wa0K4lUd7mKa0HAtZdE+jFOPDDej2" -config :domain, Domain.Clients, upstream_dns: ["1.1.1.1"] - config :domain, Domain.Gateways, gateway_ipv4_masquerade: true, gateway_ipv6_masquerade: true @@ -53,6 +51,16 @@ config :domain, Domain.Auth.Adapters.MicrosoftEntra.APIClient, config :domain, Domain.Auth.Adapters.Okta.APIClient, finch_transport_opts: [] +config :domain, Domain.Billing.Stripe.APIClient, + endpoint: "https://api.stripe.com", + finch_transport_opts: [] + +config :domain, Domain.Billing, + enabled: true, + secret_key: "sk_test_1111", + webhook_signing_secret: "whsec_test_1111", + default_price_id: "price_1OkUIcADeNU9NGxvTNA4PPq6" + config :domain, platform_adapter: nil config :domain, Domain.GoogleCloudPlatform, @@ -72,6 +80,7 @@ config :domain, Domain.Instrumentation, client_logs_bucket: "logs" config :domain, :enabled_features, + idp_sync: true, traffic_filters: true, sign_up: true, flow_activities: true, @@ -123,6 +132,8 @@ config :web, config :web, Web.Plugs.SecureHeaders, csp_policy: [ "default-src 'self' 'nonce-${nonce}'", + "frame-src 'self' https://js.stripe.com", + "script-src 'self' https://js.stripe.com", "img-src 'self' data: https://www.gravatar.com", "style-src 'self' 'unsafe-inline'" ] diff --git a/elixir/config/dev.exs b/elixir/config/dev.exs index b85b8e4ce..5a4521478 100644 --- a/elixir/config/dev.exs +++ b/elixir/config/dev.exs @@ -13,6 +13,11 @@ config :domain, Domain.Repo, config :domain, outbound_email_adapter_configured?: true +config :domain, Domain.Billing, + enabled: System.get_env("BILLING_ENABLED", "false") == "true", + secret_key: System.get_env("STRIPE_SECRET_KEY", "sk_dev_1111"), + webhook_signing_secret: System.get_env("STRIPE_WEBHOOK_SIGNING_SECRET", "whsec_dev_1111") + ############################### ##### Web ##################### ############################### @@ -62,7 +67,8 @@ config :web, Web.Plugs.SecureHeaders, "default-src 'self' 'nonce-${nonce}' https://cdn.tailwindcss.com/", "img-src 'self' data: https://www.gravatar.com", "style-src 'self' 'unsafe-inline'", - "script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com/" + "frame-src 'self' https://js.stripe.com", + "script-src 'self' 'unsafe-inline' https://js.stripe.com https://cdn.tailwindcss.com/" ] # Note: on Linux you may need to add `--add-host=host.docker.internal:host-gateway` diff --git a/elixir/config/runtime.exs b/elixir/config/runtime.exs index f19344a73..5cd932c78 100644 --- a/elixir/config/runtime.exs +++ b/elixir/config/runtime.exs @@ -31,8 +31,6 @@ if config_env() == :prod do key_base: compile_config!(:tokens_key_base), salt: compile_config!(:tokens_salt) - config :domain, Domain.Clients, upstream_dns: compile_config!(:clients_upstream_dns) - config :domain, Domain.Gateways, gateway_ipv4_masquerade: compile_config!(:gateway_ipv4_masquerade), gateway_ipv6_masquerade: compile_config!(:gateway_ipv6_masquerade) @@ -44,6 +42,16 @@ if config_env() == :prod do config :domain, Domain.Auth.Adapters.GoogleWorkspace.APIClient, finch_transport_opts: compile_config!(:http_client_ssl_opts) + config :domain, Domain.Billing.Stripe.APIClient, + endpoint: "https://api.stripe.com", + finch_transport_opts: [] + + config :domain, Domain.Billing, + enabled: compile_config!(:billing_enabled), + secret_key: compile_config!(:stripe_secret_key), + webhook_signing_secret: compile_config!(:stripe_webhook_signing_secret), + default_price_id: compile_config!(:stripe_default_price_id) + config :domain, platform_adapter: compile_config!(:platform_adapter) if platform_adapter = compile_config!(:platform_adapter) do @@ -59,6 +67,7 @@ if config_env() == :prod do client_logs_bucket: compile_config!(:instrumentation_client_logs_bucket) config :domain, :enabled_features, + idp_sync: compile_config!(:feature_idp_sync_enabled), traffic_filters: compile_config!(:feature_traffic_filters_enabled), sign_up: compile_config!(:feature_sign_up_enabled), flow_activities: compile_config!(:feature_flow_activities_enabled), diff --git a/elixir/config/test.exs b/elixir/config/test.exs index 22fea885d..4cb4ee675 100644 --- a/elixir/config/test.exs +++ b/elixir/config/test.exs @@ -42,7 +42,8 @@ config :web, Web.Plugs.SecureHeaders, "default-src 'self' 'nonce-${nonce}' https://cdn.tailwindcss.com/", "img-src 'self' data: https://www.gravatar.com", "style-src 'self' 'unsafe-inline'", - "script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com/" + "frame-src 'self' https://js.stripe.com", + "script-src 'self' 'unsafe-inline' https://js.stripe.com https://cdn.tailwindcss.com/" ] ############################### diff --git a/kotlin/android/README.md b/kotlin/android/README.md index ac80ccf02..8f1fc4d46 100644 --- a/kotlin/android/README.md +++ b/kotlin/android/README.md @@ -63,14 +63,6 @@ locally. 1. Perform a test build: `./gradlew assembleDebug`. -1. Add your debug signing key's SHA256 fingerprint to the portal's - [`assetlinks.json`](../../elixir/apps/web/priv/static/.well-known/assetlinks.json) - file. This is required for the App Links to successfully intercept the Auth - redirect. - ``` - ./gradlew signingReport - ``` - # Release Setup We release from GitHub CI, so this shouldn't be necessary. But if you're looking diff --git a/terraform/environments/production/main.tf b/terraform/environments/production/main.tf index 4f8aa99ca..c3f932a35 100644 --- a/terraform/environments/production/main.tf +++ b/terraform/environments/production/main.tf @@ -463,6 +463,23 @@ locals { name = "DOCKER_REGISTRY" value = "ghcr.io/firezone" }, + # Billing system + { + name = "BILLING_ENABLED" + value = "true" + }, + { + name = "STRIPE_SECRET_KEY" + value = var.stripe_secret_key + }, + { + name = "STRIPE_WEBHOOK_SIGNING_SECRET" + value = var.stripe_webhook_signing_secret + }, + { + name = "STRIPE_DEFAULT_PRICE_ID" + value = var.stripe_default_price_id + }, # Telemetry { name = "TELEMETRY_ENABLED" diff --git a/terraform/environments/production/variables.tf b/terraform/environments/production/variables.tf index 0ddb1e539..8305f1ef9 100644 --- a/terraform/environments/production/variables.tf +++ b/terraform/environments/production/variables.tf @@ -9,13 +9,15 @@ variable "metabase_image_tag" { } variable "relay_token" { - type = string - default = null + type = string + default = null + sensitive = true } variable "gateway_token" { - type = string - default = null + type = string + default = null + sensitive = true } variable "slack_alerts_channel" { @@ -27,16 +29,34 @@ variable "slack_alerts_channel" { variable "slack_alerts_auth_token" { type = string description = "Slack auth token for the infra alerts channel" + sensitive = true } variable "postmark_server_api_token" { - type = string + type = string + sensitive = true } variable "mailgun_server_api_token" { - type = string + type = string + sensitive = true } variable "pagerduty_auth_token" { + type = string + sensitive = true +} + +variable "stripe_secret_key" { + type = string + sensitive = true +} + +variable "stripe_webhook_signing_secret" { + type = string + sensitive = true +} + +variable "stripe_default_price_id" { type = string } diff --git a/terraform/environments/staging/main.tf b/terraform/environments/staging/main.tf index 8b9f7a4cf..e2bd3ec38 100644 --- a/terraform/environments/staging/main.tf +++ b/terraform/environments/staging/main.tf @@ -415,6 +415,23 @@ locals { name = "DOCKER_REGISTRY" value = "${module.google-artifact-registry.url}/${module.google-artifact-registry.repo}" }, + # Billing system + { + name = "BILLING_ENABLED" + value = "true" + }, + { + name = "STRIPE_SECRET_KEY" + value = var.stripe_secret_key + }, + { + name = "STRIPE_WEBHOOK_SIGNING_SECRET" + value = var.stripe_webhook_signing_secret + }, + { + name = "STRIPE_DEFAULT_PRICE_ID" + value = var.stripe_default_price_id + }, # Telemetry { name = "TELEMETRY_ENABLED" diff --git a/terraform/environments/staging/variables.tf b/terraform/environments/staging/variables.tf index 0cf8428a7..450e80502 100644 --- a/terraform/environments/staging/variables.tf +++ b/terraform/environments/staging/variables.tf @@ -37,3 +37,17 @@ variable "postmark_server_api_token" { variable "mailgun_server_api_token" { type = string } + +variable "stripe_secret_key" { + type = string + sensitive = true +} + +variable "stripe_webhook_signing_secret" { + type = string + sensitive = true +} + +variable "stripe_default_price_id" { + type = string +}