feat(portal): Billing system (#3642)

This commit is contained in:
Andrew Dryga
2024-02-20 15:01:17 -06:00
committed by GitHub
parent 3d3e737ba3
commit 5b1e3ea1d1
128 changed files with 4111 additions and 1160 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -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"
})
```

View File

@@ -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

View File

@@ -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}

View File

@@ -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)
%{

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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"],

View 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

View 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

View File

@@ -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")

View File

@@ -44,6 +44,7 @@ defmodule API.MixProject do
# Phoenix deps
{:phoenix, "~> 1.7.0"},
{:phoenix_ecto, "~> 4.4"},
{:plug_cowboy, "~> 2.7"},
# Observability deps

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -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()

View File

@@ -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

View File

@@ -36,7 +36,8 @@ defmodule Domain.Application do
Domain.Auth,
Domain.Relays,
Domain.Gateways,
Domain.Clients
Domain.Clients,
Domain.Billing
# Observability
# Domain.Telemetry

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View 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

View File

@@ -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

View 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

View 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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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(%{

View File

@@ -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()

View File

@@ -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

View File

@@ -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()

View File

@@ -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()

View File

@@ -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

View 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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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"}}

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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"

View File

@@ -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>

View File

@@ -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()]}>

View File

@@ -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"],

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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}"
)

View File

@@ -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}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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")),

View File

@@ -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

View File

@@ -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: [

View File

@@ -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)

View File

@@ -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"}})

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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>

View 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

View File

@@ -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