Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion lib/supavisor/client_handler.ex
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,8 @@ defmodule Supavisor.ClientHandler do
upstream_tls: upstream_tls
)

with :ok <- Checks.check_ssl_enforcement(data, info, user),
with :ok <- Checks.check_tenant_not_banned(info),
:ok <- Checks.check_ssl_enforcement(data, info, user),
:ok <- Checks.check_address_allowed(sock, info),
:ok <- Manager.check_client_limit(id, info, data.mode),
{:ok, auth_method} <-
Expand Down
17 changes: 16 additions & 1 deletion lib/supavisor/client_handler/checks.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,22 @@ defmodule Supavisor.ClientHandler.Checks do
# TODO: remove the dependency, move the functions here
alias Supavisor.HandlerHelpers

alias Supavisor.Errors.{AddressNotAllowedError, SslRequiredError}
alias Supavisor.Errors.{AddressNotAllowedError, SslRequiredError, TenantBannedError}

def check_tenant_not_banned(%{tenant: %{banned_at: nil}}), do: :ok

def check_tenant_not_banned(%{tenant: %{banned_until: banned_until, ban_reason: reason}})
when not is_nil(banned_until) do
if DateTime.compare(banned_until, DateTime.utc_now()) == :lt do
:ok
else
{:error, %TenantBannedError{ban_reason: reason}}
end
end

def check_tenant_not_banned(%{tenant: %{ban_reason: reason}}) do
{:error, %TenantBannedError{ban_reason: reason}}
end

def check_ssl_enforcement(data, info, user) do
if !data.local and info.tenant.enforce_ssl and !data.ssl do
Expand Down
18 changes: 18 additions & 0 deletions lib/supavisor/errors/tenant_banned_error.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
defmodule Supavisor.Errors.TenantBannedError do
@moduledoc """
This error is returned when a client attempts to connect to a banned tenant.
"""

use Supavisor.Error, [:ban_reason, code: "EBANNED"]

@type t() :: %__MODULE__{
ban_reason: binary() | nil,
code: binary()
}

@impl Supavisor.Error
def error_message(%{ban_reason: reason}), do: "tenant is banned: #{reason}"

@impl Supavisor.Error
def log_level(_), do: :warning
end
34 changes: 34 additions & 0 deletions lib/supavisor/tenants.ex
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,40 @@ defmodule Supavisor.Tenants do
|> with_cache_invalidation(operation: "update", terminate_pools: true)
end

@doc """
Toggles the ban status of a tenant.

When banning a tenant, the `ban_reason` field is required. When unbanning, it is ignored.

## Examples

iex> toggle_tenant_ban("external_id", %{banned: true, ban_reason: "violation"})
{:ok, %Tenant{}}

iex> toggle_tenant_ban("external_id", %{banned: false})
{:ok, %Tenant{}}
"""
@spec toggle_tenant_ban(String.t(), map()) ::
{:ok, Tenant.t()} | {:error, :not_found | Ecto.Changeset.t()}
def toggle_tenant_ban(external_id, %{"banned" => banned?} = params) do
case get_tenant_by_external_id(external_id) do
nil ->
{:error, :not_found}

tenant when banned? == "true" ->
tenant
|> Tenant.ban_changeset(params)
|> Repo.update()
|> with_cache_invalidation(operation: "ban")

tenant when banned? == "false" ->
tenant
|> Tenant.unban_changeset(params)
|> Repo.update()
|> with_cache_invalidation(operation: "unban")
end
end

def update_tenant_ps(external_id, new_ps) do
from(t in Tenant, where: t.external_id == ^external_id)
|> Repo.one()
Expand Down
15 changes: 15 additions & 0 deletions lib/supavisor/tenants/tenant.ex
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ defmodule Supavisor.Tenants.Tenant do
field(:feature_flags, :map, default: %{})
field(:use_jit, :boolean, default: false)
field(:jit_api_url, :string)
field(:banned_at, :utc_datetime)
field(:ban_reason, :string)
field(:banned_until, :utc_datetime)

has_many(:users, User,
foreign_key: :tenant_external_id,
Expand Down Expand Up @@ -131,4 +134,16 @@ defmodule Supavisor.Tenants.Tenant do
defp valid_range?(range) do
match?({:ok, _}, InetCidr.parse_cidr(range))
end

@doc false
def ban_changeset(tenant, %{"banned" => "true"} = params) do
tenant
|> cast(params, [:ban_reason, :banned_until])
|> validate_required([:ban_reason])
|> put_change(:banned_at, DateTime.utc_now() |> DateTime.truncate(:second))
end

def unban_changeset(tenant, %{"banned" => "false"}) do
change(tenant, banned_at: nil, ban_reason: nil, banned_until: nil)
end
end
47 changes: 46 additions & 1 deletion lib/supavisor_web/controllers/tenant_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,15 @@ defmodule SupavisorWeb.TenantController do
UserCredentialsUpdate
}

# TODO: fix the other actions to use CastAndValidate and remove this conditional plug
plug OpenApiSpex.Plug.CastAndValidate,
[json_render_error_v2: true, replace_params: false] when action == :patch

action_fallback(SupavisorWeb.FallbackController)

@authorization [
in: :header,
name: "Authorization",
name: :authorization,
schema: %OpenApiSpex.Schema{type: :string},
required: true,
example:
Expand Down Expand Up @@ -294,6 +298,47 @@ defmodule SupavisorWeb.TenantController do
end
end

operation(:patch,
summary: "Update tenant",
description: """
Ban or unban a tenant by setting the `banned` field to true or false, respectively.
When set to `true`, `ban_reason` must be provided.

While banned, any client attempting to connect will receive a FATAL error on the wire.
""",
parameters: [
external_id: [in: :path, description: "External id", type: :string],
authorization: @authorization
],
request_body:
{"Update tenant params", "application/json", SupavisorWeb.OpenApiSchemas.ToggleTenantBan,
required: true},
responses: %{
200 => TenantData.response(),
404 => NotFound.response(),
422 => UnprocessablyEntity.response()
}
)

def patch(conn, %{"external_id" => id, "banned" => _} = params) do
case Tenants.toggle_tenant_ban(id, params) do
{:ok, tenant} ->
tenant = Repo.preload(tenant, :users)
render(conn, "show.json", tenant: tenant)

{:error, :not_found} ->
conn
|> put_status(404)
|> render("not_found.json", tenant: nil)

{:error, %Ecto.Changeset{} = changeset} ->
conn
|> put_status(422)
|> put_view(SupavisorWeb.ChangesetView)
|> render("error.json", changeset: changeset)
end
end

operation(:list_network_bans,
summary: "List network bans for tenant",
description: """
Expand Down
48 changes: 48 additions & 0 deletions lib/supavisor_web/open_api_schemas.ex
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,19 @@ defmodule SupavisorWeb.OpenApiSchemas do
nullable: true
},
users: %Schema{type: :array, items: User},
banned_at: %Schema{
type: :string,
format: :date_time,
description: "Ban timestamp",
nullable: true
},
ban_reason: %Schema{type: :string, description: "Reason for ban", nullable: true},
banned_until: %Schema{
type: :string,
format: :date_time,
description: "Ban expiry timestamp",
nullable: true
},
inserted_at: %Schema{type: :string, format: :date_time, readOnly: true},
updated_at: %Schema{type: :string, format: :date_time, readOnly: true}
},
Expand All @@ -97,6 +110,9 @@ defmodule SupavisorWeb.OpenApiSchemas do
inserted_at: "2023-03-27T12:00:00Z",
updated_at: "2023-03-27T12:00:00Z",
allow_list: ["0.0.0.0/0", "::/0"],
banned_at: "2026-01-01T00:00:00Z",
ban_reason: "abuse",
banned_until: "2926-04-01T00:00:00Z",
users: [
%{
id: "b1024a4c-4eb4-4c64-8f49-c8a46c2b2e16",
Expand Down Expand Up @@ -370,4 +386,36 @@ defmodule SupavisorWeb.OpenApiSchemas do

def params, do: {"Clear Network Ban Params", "application/json", __MODULE__}
end

defmodule ToggleTenantBan do
@moduledoc false
require OpenApiSpex

OpenApiSpex.schema(%{
type: :object,
properties: %{
banned: %Schema{
type: :boolean,
description: "Set to true to ban the tenant, false to unban"
},
ban_reason: %Schema{
type: :string,
description: "Reason for the ban (required when banned is true)"
},
banned_until: %Schema{
type: :string,
format: :date_time,
description: "Optional ban expiry timestamp"
}
},
required: [:banned],
example: %{
banned: true,
ban_reason: "Acceptable use policy violation",
banned_until: "2026-01-01T00:00:00Z"
}
})

def params, do: {"ToggleTenant Ban Params", "application/json", __MODULE__}
end
end
2 changes: 2 additions & 0 deletions lib/supavisor_web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ defmodule SupavisorWeb.Router do
pipeline :api do
plug(:accepts, ["json"])
plug(:check_auth, [:api_jwt_secret, :api_blocklist])
plug(OpenApiSpex.Plug.PutApiSpec, module: SupavisorWeb.ApiSpec)
end

pipeline :metrics do
Expand Down Expand Up @@ -43,6 +44,7 @@ defmodule SupavisorWeb.Router do

get("/tenants/:external_id", TenantController, :show)
put("/tenants/:external_id", TenantController, :update)
patch("/tenants/:external_id", TenantController, :patch)
delete("/tenants/:external_id", TenantController, :delete)
get("/tenants/:external_id/terminate", TenantController, :terminate)

Expand Down
11 changes: 11 additions & 0 deletions priv/repo/migrations/20260323000000_add_tenant_ban_fields.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
defmodule Supavisor.Repo.Migrations.AddTenantBanFields do
use Ecto.Migration

def change do
alter table(:tenants, prefix: "_supavisor") do
add(:banned_at, :utc_datetime, null: true)
add(:ban_reason, :string, null: true)
add(:banned_until, :utc_datetime, null: true)
end
end
end
46 changes: 46 additions & 0 deletions test/integration/proxy_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -892,6 +892,52 @@ defmodule Supavisor.Integration.ProxyTest do
GenServer.stop(node2_conn)
end

describe "banned tenant" do
@ban_tenant "proxy_tenant_ps_enabled"

setup do
on_exit(fn ->
Supavisor.Tenants.toggle_tenant_ban(@ban_tenant, %{"banned" => "false"})
Supavisor.del_all_cache_dist(@ban_tenant)
end)

:ok
end

test "connecting to a banned tenant receives FATAL error on the wire" do
db_conf = Application.get_env(:supavisor, Supavisor.Repo)

url =
"postgresql://#{db_conf[:username]}.#{@ban_tenant}:#{db_conf[:password]}@#{db_conf[:hostname]}:#{Application.get_env(:supavisor, :proxy_port_transaction)}/#{db_conf[:database]}"

# Ban the tenant and clear cache so ClientHandler picks it up fresh
{:ok, _} =
Supavisor.Tenants.toggle_tenant_ban(@ban_tenant, %{
"banned" => "true",
"ban_reason" => "integration test ban"
})

Supavisor.del_all_cache_dist(@ban_tenant)

assert {:error,
%Postgrex.Error{
postgres: %{
code: :internal_error,
message: "(EBANNED) tenant is banned: integration test ban",
pg_code: "XX000",
severity: "FATAL"
}
}} = parse_uri(url) |> single_connection()

# Unban and verify the connection succeeds again
{:ok, _} = Supavisor.Tenants.toggle_tenant_ban(@ban_tenant, %{"banned" => "false"})
Supavisor.del_all_cache_dist(@ban_tenant)

# run the actual query on the connection to verify it works, not just that it connects
assert {:ok, _pid} = single_connection(parse_uri(url))
end
end

defp parse_uri(uri) do
%URI{
userinfo: userinfo,
Expand Down
Loading
Loading