mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 02:18:47 +00:00
feat(portal): Billing system (#3642)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
})
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
%{
|
||||
|
||||
@@ -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
|
||||
10
elixir/apps/api/lib/api/controllers/health_controller.ex
Normal file
10
elixir/apps/api/lib/api/controllers/health_controller.ex
Normal file
@@ -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
|
||||
@@ -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
|
||||
@@ -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"],
|
||||
|
||||
74
elixir/apps/api/lib/api/plugs/auth.ex
Normal file
74
elixir/apps/api/lib/api/plugs/auth.ex
Normal file
@@ -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
|
||||
35
elixir/apps/api/lib/api/router.ex
Normal file
35
elixir/apps/api/lib/api/router.ex
Normal file
@@ -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
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ defmodule API.MixProject do
|
||||
|
||||
# Phoenix deps
|
||||
{:phoenix, "~> 1.7.0"},
|
||||
{:phoenix_ecto, "~> 4.4"},
|
||||
{:plug_cowboy, "~> 2.7"},
|
||||
|
||||
# Observability deps
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
13
elixir/apps/domain/lib/domain/accounts/config.ex
Normal file
13
elixir/apps/domain/lib/domain/accounts/config.ex
Normal file
@@ -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
|
||||
79
elixir/apps/domain/lib/domain/accounts/config/changeset.ex
Normal file
79
elixir/apps/domain/lib/domain/accounts/config/changeset.ex
Normal file
@@ -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
|
||||
12
elixir/apps/domain/lib/domain/accounts/features.ex
Normal file
12
elixir/apps/domain/lib/domain/accounts/features.ex
Normal file
@@ -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
|
||||
11
elixir/apps/domain/lib/domain/accounts/features/changeset.ex
Normal file
11
elixir/apps/domain/lib/domain/accounts/features/changeset.ex
Normal file
@@ -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
|
||||
11
elixir/apps/domain/lib/domain/accounts/limits.ex
Normal file
11
elixir/apps/domain/lib/domain/accounts/limits.ex
Normal file
@@ -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
|
||||
15
elixir/apps/domain/lib/domain/accounts/limits/changeset.ex
Normal file
15
elixir/apps/domain/lib/domain/accounts/limits/changeset.ex
Normal file
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -36,7 +36,8 @@ defmodule Domain.Application do
|
||||
Domain.Auth,
|
||||
Domain.Relays,
|
||||
Domain.Gateways,
|
||||
Domain.Clients
|
||||
Domain.Clients,
|
||||
Domain.Billing
|
||||
|
||||
# Observability
|
||||
# Domain.Telemetry
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
366
elixir/apps/domain/lib/domain/billing.ex
Normal file
366
elixir/apps/domain/lib/domain/billing.ex
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
84
elixir/apps/domain/lib/domain/billing/jobs.ex
Normal file
84
elixir/apps/domain/lib/domain/billing/jobs.ex
Normal file
@@ -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
|
||||
104
elixir/apps/domain/lib/domain/billing/stripe/api_client.ex
Normal file
104
elixir/apps/domain/lib/domain/billing/stripe/api_client.ex
Normal file
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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(%{
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
71
elixir/apps/domain/test/domain/billing/jobs_test.exs
Normal file
71
elixir/apps/domain/test/domain/billing/jobs_test.exs
Normal file
@@ -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
|
||||
231
elixir/apps/domain/test/domain/billing_test.exs
Normal file
231
elixir/apps/domain/test/domain/billing_test.exs
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
378
elixir/apps/domain/test/support/mocks/stripe.ex
Normal file
378
elixir/apps/domain/test/support/mocks/stripe.ex
Normal file
@@ -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
|
||||
@@ -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 <span>(beta)</span>
|
||||
"""
|
||||
end
|
||||
|
||||
def feature_name(%{feature: :multi_site_resources} = assigns) do
|
||||
~H"""
|
||||
Define globally-distributed resources <span>(beta)</span>
|
||||
"""
|
||||
end
|
||||
|
||||
def feature_name(%{feature: :traffic_filters} = assigns) do
|
||||
~H"""
|
||||
Filter traffic using protocol and port rules <span>(beta)</span>
|
||||
"""
|
||||
end
|
||||
|
||||
def feature_name(%{feature: :self_hosted_relays} = assigns) do
|
||||
~H"""
|
||||
Host your own relays <span>(beta)</span>
|
||||
"""
|
||||
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
|
||||
|
||||
@@ -267,7 +267,7 @@ defmodule Web.FormComponents do
|
||||
]}>
|
||||
<span
|
||||
class={[
|
||||
"bg-neutral-200 whitespace-nowrap rounded-e-0 rounded-s inline-flex items-center px-3"
|
||||
"bg-neutral-100 whitespace-nowrap rounded-e-0 rounded-s inline-flex items-center px-3"
|
||||
]}
|
||||
id={"#{@id}-prefix"}
|
||||
phx-hook="Refocus"
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
</.sidebar_item>
|
||||
|
||||
<.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</:name>
|
||||
|
||||
<:item navigate={~p"/#{@account}/settings/account"}>Account</:item>
|
||||
<:item
|
||||
:if={Domain.Billing.account_provisioned?(@account)}
|
||||
navigate={~p"/#{@account}/settings/billing"}
|
||||
>
|
||||
Billing
|
||||
</:item>
|
||||
<:item navigate={~p"/#{@account}/settings/identity_providers"}>
|
||||
Identity Providers
|
||||
</:item>
|
||||
@@ -80,5 +86,30 @@
|
||||
</.sidebar>
|
||||
|
||||
<main class="md:ml-64 h-auto pt-14">
|
||||
<.flash :if={@account.warning} kind={:warning}>
|
||||
<%= @account.warning %>.
|
||||
<span :if={Domain.Billing.account_provisioned?(@account)}>
|
||||
Please
|
||||
<.link navigate={~p"/#{@account}/settings/billing"} class={link_style()}>
|
||||
check your billing information
|
||||
</.link>
|
||||
to continue using Firezone.
|
||||
</span>
|
||||
</.flash>
|
||||
|
||||
<.flash :if={not Domain.Accounts.account_active?(@account)} kind={:error}>
|
||||
This account has been disabled.
|
||||
<span>
|
||||
Please
|
||||
<.link
|
||||
class={link_style()}
|
||||
href={mailto_support(@account, @subject, "Enable account: #{@account.name}")}
|
||||
>
|
||||
contact support
|
||||
</.link>
|
||||
to re-activate it.
|
||||
</span>
|
||||
</.flash>
|
||||
|
||||
<%= @inner_content %>
|
||||
</main>
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
<p>Your account ID can be found in your welcome email.</p>
|
||||
|
||||
<.button class="w-full">
|
||||
Go to Sign In page
|
||||
</.button>
|
||||
</.form>
|
||||
|
||||
<p
|
||||
:if={
|
||||
Domain.Config.sign_up_enabled?() and
|
||||
Web.Auth.fetch_auth_context_type!(@params) == :browser
|
||||
}
|
||||
class="py-2"
|
||||
class="py-2 text-center"
|
||||
>
|
||||
Don't have an account?
|
||||
<a href={~p"/sign_up"} class={[link_style()]}>
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}})
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ defmodule Web.Resources.Index do
|
||||
</:help>
|
||||
<: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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
</:title>
|
||||
<: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
|
||||
|
||||
@@ -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
|
||||
</:title>
|
||||
<:content>
|
||||
<div class="bg-white overflow-hidden">
|
||||
<.vertical_table id="account">
|
||||
<.vertical_table_row>
|
||||
<:label>Account Name</:label>
|
||||
<:value><%= @account.name %></:value>
|
||||
</.vertical_table_row>
|
||||
<.vertical_table_row>
|
||||
<:label>Account ID</:label>
|
||||
<:value><%= @account.id %></:value>
|
||||
</.vertical_table_row>
|
||||
<.vertical_table_row>
|
||||
<:label>Account Slug</:label>
|
||||
<:value>
|
||||
<.copy id="account-slug"><%= @account.slug %></.copy>
|
||||
</:value>
|
||||
</.vertical_table_row>
|
||||
</.vertical_table>
|
||||
</div>
|
||||
<.vertical_table id="account">
|
||||
<.vertical_table_row>
|
||||
<:label>Account Name</:label>
|
||||
<:value><%= @account.name %></:value>
|
||||
</.vertical_table_row>
|
||||
<.vertical_table_row>
|
||||
<:label>Account ID</:label>
|
||||
<:value><%= @account.id %></:value>
|
||||
</.vertical_table_row>
|
||||
<.vertical_table_row>
|
||||
<:label>Account Slug</:label>
|
||||
<:value>
|
||||
<.copy id="account-slug"><%= @account.slug %></.copy>
|
||||
</:value>
|
||||
</.vertical_table_row>
|
||||
</.vertical_table>
|
||||
</:content>
|
||||
</.section>
|
||||
|
||||
<.section>
|
||||
<:title>
|
||||
Danger zone
|
||||
@@ -45,10 +50,11 @@ defmodule Web.Settings.Account do
|
||||
Terminate account
|
||||
</h3>
|
||||
<p class="ml-4 mb-4 text-neutral-600">
|
||||
<.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
|
||||
<span :if={Accounts.account_active?(@account)}>disable your account and</span>
|
||||
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</.link>.
|
||||
</p>
|
||||
</:content>
|
||||
|
||||
215
elixir/apps/web/lib/web/live/settings/billing.ex
Normal file
215
elixir/apps/web/lib/web/live/settings/billing.ex
Normal file
@@ -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</.breadcrumb>
|
||||
</.breadcrumbs>
|
||||
|
||||
<.section>
|
||||
<:title>
|
||||
Billing Information
|
||||
</:title>
|
||||
<:action>
|
||||
<.button icon="hero-pencil" phx-click="redirect_to_billing_portal">
|
||||
Manage
|
||||
</.button>
|
||||
<.button navigate={
|
||||
mailto_support(
|
||||
@account,
|
||||
@subject,
|
||||
"Billing question: #{@account.name}"
|
||||
)
|
||||
}>
|
||||
Contact Sales Team
|
||||
</.button>
|
||||
</:action>
|
||||
<:content>
|
||||
<.flash :if={@error} kind={:error}>
|
||||
<p><%= @error %></p>
|
||||
|
||||
<p>
|
||||
If you need assistance, please <.link
|
||||
class={link_style()}
|
||||
href={
|
||||
mailto_support(
|
||||
@account,
|
||||
@subject,
|
||||
"Issues accessing billing portal: #{@account.name}"
|
||||
)
|
||||
}
|
||||
>contact support</.link>.
|
||||
</p>
|
||||
</.flash>
|
||||
|
||||
<.vertical_table id="billing">
|
||||
<.vertical_table_row>
|
||||
<:label>Current Plan</:label>
|
||||
<:value>
|
||||
<%= @account.metadata.stripe.product_name %>
|
||||
</:value>
|
||||
</.vertical_table_row>
|
||||
|
||||
<.vertical_table_row :if={not is_nil(@account.limits.monthly_active_users_count)}>
|
||||
<:label>
|
||||
<p>Seats</p>
|
||||
</:label>
|
||||
<:value>
|
||||
<span class={[
|
||||
not is_nil(@active_users_count) and
|
||||
@active_users_count > @account.limits.monthly_active_users_count && "text-red-500"
|
||||
]}>
|
||||
<%= @active_users_count %> used
|
||||
</span>
|
||||
/ <%= @account.limits.monthly_active_users_count %> allowed
|
||||
<p class="text-xs">users with at least one device signed-in within last month</p>
|
||||
</:value>
|
||||
</.vertical_table_row>
|
||||
|
||||
<.vertical_table_row :if={not is_nil(@account.limits.service_accounts_count)}>
|
||||
<:label>
|
||||
<p>Service Accounts</p>
|
||||
</:label>
|
||||
<:value>
|
||||
<span class={[
|
||||
not is_nil(@service_accounts_count) and
|
||||
@service_accounts_count > @account.limits.service_accounts_count && "text-red-500"
|
||||
]}>
|
||||
<%= @service_accounts_count %> used
|
||||
</span>
|
||||
/ <%= @account.limits.service_accounts_count %> allowed
|
||||
<p class="text-xs">users with at least one device signed-in within last month</p>
|
||||
</:value>
|
||||
</.vertical_table_row>
|
||||
|
||||
<.vertical_table_row :if={not is_nil(@account.limits.account_admin_users_count)}>
|
||||
<:label>
|
||||
<p>Admins</p>
|
||||
</:label>
|
||||
<:value>
|
||||
<span class={[
|
||||
not is_nil(@admins_count) and
|
||||
@admins_count > @account.limits.account_admin_users_count && "text-red-500"
|
||||
]}>
|
||||
<%= @admins_count %> used
|
||||
</span>
|
||||
/ <%= @account.limits.account_admin_users_count %> allowed
|
||||
</:value>
|
||||
</.vertical_table_row>
|
||||
|
||||
<.vertical_table_row :if={not is_nil(@account.limits.gateway_groups_count)}>
|
||||
<:label>
|
||||
<p>Sites</p>
|
||||
</:label>
|
||||
<:value>
|
||||
<span class={[
|
||||
not is_nil(@gateway_groups_count) and
|
||||
@gateway_groups_count > @account.limits.gateway_groups_count && "text-red-500"
|
||||
]}>
|
||||
<%= @gateway_groups_count %> used
|
||||
</span>
|
||||
/ <%= @account.limits.gateway_groups_count %> allowed
|
||||
</:value>
|
||||
</.vertical_table_row>
|
||||
</.vertical_table>
|
||||
</:content>
|
||||
</.section>
|
||||
|
||||
<.section>
|
||||
<:title>
|
||||
Enabled Enterprise Features
|
||||
</:title>
|
||||
<:help>
|
||||
For further details on enrolling in beta features, reach out to your account manager
|
||||
</:help>
|
||||
<:content>
|
||||
<.vertical_table id="features">
|
||||
<.vertical_table_row :for={
|
||||
{key, _value} <- Map.delete(Map.from_struct(@account.features), :limits)
|
||||
}>
|
||||
<:label><.feature_name feature={key} /></:label>
|
||||
<: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"
|
||||
/>
|
||||
</:value>
|
||||
</.vertical_table_row>
|
||||
</.vertical_table>
|
||||
</:content>
|
||||
</.section>
|
||||
|
||||
<.section>
|
||||
<:title>
|
||||
Danger zone
|
||||
</:title>
|
||||
<:content>
|
||||
<h3 class="ml-4 mb-4 font-medium text-neutral-900">
|
||||
Terminate account
|
||||
</h3>
|
||||
<p class="ml-4 mb-4 text-neutral-600">
|
||||
<.icon name="hero-exclamation-circle" class="inline-block w-5 h-5 mr-1 text-red-500" /> To
|
||||
<span :if={Accounts.account_active?(@account)}>disable your account and</span>
|
||||
schedule it for deletion, please <.link
|
||||
class={link_style()}
|
||||
href={mailto_support(@account, @subject, "Account termination request: #{@account.name}")}
|
||||
>contact support</.link>.
|
||||
</p>
|
||||
</:content>
|
||||
</.section>
|
||||
"""
|
||||
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
|
||||
@@ -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</.breadcrumb>
|
||||
@@ -56,26 +57,37 @@ defmodule Web.Settings.DNS do
|
||||
<.form for={@form} phx-submit={:submit} phx-change={:change}>
|
||||
<div class="grid gap-4 mb-4 sm:grid-cols-1 sm:gap-6 sm:mb-6">
|
||||
<div>
|
||||
<.inputs_for :let={dns} field={@form[:clients_upstream_dns]}>
|
||||
<div class="flex gap-4 items-start mb-2">
|
||||
<div class="w-1/4">
|
||||
<.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]}>
|
||||
<div class="flex gap-4 items-start mb-2">
|
||||
<div class="w-1/4">
|
||||
<.input
|
||||
type="select"
|
||||
label="Protocol"
|
||||
field={dns[:protocol]}
|
||||
placeholder="Protocol"
|
||||
options={dns_options()}
|
||||
value={dns[:protocol].value}
|
||||
/>
|
||||
</div>
|
||||
<div class="w-3/4">
|
||||
<.input
|
||||
label="Address"
|
||||
field={dns[:address]}
|
||||
placeholder="DNS Server Address"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-3/4">
|
||||
<.input label="Address" field={dns[:address]} placeholder="DNS Server Address" />
|
||||
</div>
|
||||
</div>
|
||||
</.inputs_for>
|
||||
<% errors =
|
||||
translate_errors(
|
||||
@form.source.changes.config.errors,
|
||||
:clients_upstream_dns
|
||||
) %>
|
||||
<.error :for={error <- errors} data-validation-error-for="clients_upstream_dns">
|
||||
<%= error %>
|
||||
</.error>
|
||||
</.inputs_for>
|
||||
<.error :for={msg <- @errors} data-validation-error-for="clients_upstream_dns">
|
||||
<%= msg %>
|
||||
</.error>
|
||||
</div>
|
||||
<.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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user