mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 10:18:54 +00:00
feat(portal): Allow client verification and add a policy condition to enforce it (#6604)
<img width="1414" alt="Screenshot 2024-09-05 at 1 17 08 PM" src="https://github.com/user-attachments/assets/f50816e5-1e16-413c-be35-15ef9153a95d"> <img width="1404" alt="Screenshot 2024-09-05 at 1 17 13 PM" src="https://github.com/user-attachments/assets/a5e055d0-321d-417e-9fd8-78e9643498cd"> <img width="1178" alt="Screenshot 2024-09-05 at 1 17 23 PM" src="https://github.com/user-attachments/assets/6ea45486-98fb-495f-96d9-a96eb01925dd"> <img width="678" alt="Screenshot 2024-09-05 at 1 17 31 PM" src="https://github.com/user-attachments/assets/45b4e798-d1b8-4574-97b3-a41dec1619fd"> <img width="632" alt="Screenshot 2024-09-05 at 1 17 46 PM" src="https://github.com/user-attachments/assets/3c7c02e0-fc78-442e-86d3-fa711c9bb77c">
This commit is contained in:
@@ -19,7 +19,7 @@ defmodule Domain.Actors.Group.Changeset do
|
||||
|> changeset()
|
||||
|> put_change(:account_id, account.id)
|
||||
|> cast_membership_assocs(account.id)
|
||||
|> put_created_by(subject)
|
||||
|> put_subject_trail(:created_by, subject)
|
||||
end
|
||||
|
||||
def create(%Accounts.Account{} = account, attrs) do
|
||||
|
||||
@@ -184,6 +184,40 @@ defmodule Domain.Clients do
|
||||
end
|
||||
end
|
||||
|
||||
def verify_client(%Client{} = client, %Auth.Subject{} = subject) do
|
||||
with :ok <- authorize_actor_client_management(client.actor_id, subject),
|
||||
:ok <- Auth.ensure_has_permissions(subject, Authorizer.verify_clients_permission()) do
|
||||
Client.Query.not_deleted()
|
||||
|> Client.Query.by_id(client.id)
|
||||
|> Authorizer.for_subject(subject)
|
||||
|> Repo.fetch_and_update(Client.Query,
|
||||
with: &Client.Changeset.verify(&1, subject),
|
||||
preload: [:online?]
|
||||
)
|
||||
|> case do
|
||||
{:ok, client} ->
|
||||
client = Repo.preload(client, [:verified_by_actor, :verified_by_identity])
|
||||
{:ok, client}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def remove_client_verification(%Client{} = client, %Auth.Subject{} = subject) do
|
||||
with :ok <- authorize_actor_client_management(client.actor_id, subject),
|
||||
:ok <- Auth.ensure_has_permissions(subject, Authorizer.verify_clients_permission()) do
|
||||
Client.Query.not_deleted()
|
||||
|> Client.Query.by_id(client.id)
|
||||
|> Authorizer.for_subject(subject)
|
||||
|> Repo.fetch_and_update(Client.Query,
|
||||
with: &Client.Changeset.remove_verification(&1),
|
||||
preload: [:online?]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def delete_client(%Client{} = client, %Auth.Subject{} = subject) do
|
||||
queryable =
|
||||
Client.Query.not_deleted()
|
||||
|
||||
@@ -4,13 +4,15 @@ defmodule Domain.Clients.Authorizer do
|
||||
|
||||
def manage_own_clients_permission, do: build(Client, :manage_own)
|
||||
def manage_clients_permission, do: build(Client, :manage)
|
||||
def verify_clients_permission, do: build(Client, :verify)
|
||||
|
||||
@impl Domain.Auth.Authorizer
|
||||
|
||||
def list_permissions_for_role(:account_admin_user) do
|
||||
[
|
||||
manage_own_clients_permission(),
|
||||
manage_clients_permission()
|
||||
manage_clients_permission(),
|
||||
verify_clients_permission()
|
||||
]
|
||||
end
|
||||
|
||||
|
||||
@@ -27,6 +27,11 @@ defmodule Domain.Clients.Client do
|
||||
belongs_to :identity, Domain.Auth.Identity
|
||||
belongs_to :last_used_token, Domain.Tokens.Token
|
||||
|
||||
field :verified_at, :utc_datetime_usec
|
||||
field :verified_by, Ecto.Enum, values: [:system, :actor, :identity]
|
||||
belongs_to :verified_by_actor, Domain.Actors.Actor
|
||||
belongs_to :verified_by_identity, Domain.Auth.Identity
|
||||
|
||||
field :deleted_at, :utc_datetime_usec
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@@ -72,6 +72,22 @@ defmodule Domain.Clients.Client.Changeset do
|
||||
|> unique_constraint(:ipv6, name: :clients_account_id_ipv6_index)
|
||||
end
|
||||
|
||||
def verify(%Clients.Client{} = client, %Auth.Subject{} = subject) do
|
||||
client
|
||||
|> change()
|
||||
|> put_default_value(:verified_at, DateTime.utc_now())
|
||||
|> put_subject_trail(:verified_by, subject)
|
||||
end
|
||||
|
||||
def remove_verification(%Clients.Client{} = client) do
|
||||
client
|
||||
|> change()
|
||||
|> put_change(:verified_at, nil)
|
||||
|> put_change(:verified_by, nil)
|
||||
|> put_change(:verified_by_actor_id, nil)
|
||||
|> put_change(:verified_by_identity_id, nil)
|
||||
end
|
||||
|
||||
def update(%Clients.Client{} = client, attrs) do
|
||||
client
|
||||
|> cast(attrs, @update_fields)
|
||||
|
||||
@@ -113,6 +113,16 @@ defmodule Domain.Clients.Client.Query do
|
||||
type: {:string, :websearch},
|
||||
fun: &filter_by_name_fts/2
|
||||
},
|
||||
%Domain.Repo.Filter{
|
||||
name: :verification,
|
||||
title: "Verification Status",
|
||||
type: :string,
|
||||
values: [
|
||||
{"Verified", "verified"},
|
||||
{"Not Verified", "not_verified"}
|
||||
],
|
||||
fun: &filter_by_verification/2
|
||||
},
|
||||
%Domain.Repo.Filter{
|
||||
name: :client_or_actor_name,
|
||||
title: "Client Name or Actor Name",
|
||||
@@ -125,6 +135,14 @@ defmodule Domain.Clients.Client.Query do
|
||||
{queryable, dynamic([clients: clients], fulltext_search(clients.name, ^name))}
|
||||
end
|
||||
|
||||
def filter_by_verification(queryable, "verified") do
|
||||
{queryable, dynamic([clients: clients], not is_nil(clients.verified_at))}
|
||||
end
|
||||
|
||||
def filter_by_verification(queryable, "not_verified") do
|
||||
{queryable, dynamic([clients: clients], is_nil(clients.verified_at))}
|
||||
end
|
||||
|
||||
def filter_by_client_or_actor_name(queryable, name) do
|
||||
queryable =
|
||||
with_named_binding(queryable, :actor, fn queryable, binding ->
|
||||
|
||||
@@ -9,14 +9,14 @@ defmodule Domain.Gateways.Group.Changeset do
|
||||
%Gateways.Group{account: account}
|
||||
|> changeset(attrs)
|
||||
|> put_change(:account_id, account.id)
|
||||
|> put_created_by(subject)
|
||||
|> put_subject_trail(:created_by, subject)
|
||||
end
|
||||
|
||||
def create(%Accounts.Account{} = account, attrs) do
|
||||
%Gateways.Group{account: account}
|
||||
|> changeset(attrs)
|
||||
|> put_change(:account_id, account.id)
|
||||
|> put_created_by(:system)
|
||||
|> put_subject_trail(:created_by, :system)
|
||||
end
|
||||
|
||||
def update(%Gateways.Group{} = group, attrs, %Auth.Subject{}) do
|
||||
|
||||
@@ -3,15 +3,21 @@ defmodule Domain.Policies.Condition do
|
||||
|
||||
@primary_key false
|
||||
embedded_schema do
|
||||
field :property, Ecto.Enum,
|
||||
values: ~w[remote_ip_location_region remote_ip provider_id current_utc_datetime]a
|
||||
field :property, Ecto.Enum, values: ~w[
|
||||
remote_ip_location_region
|
||||
remote_ip
|
||||
provider_id
|
||||
current_utc_datetime
|
||||
client_verified
|
||||
]a
|
||||
|
||||
field :operator, Ecto.Enum, values: ~w[
|
||||
contains does_not_contain
|
||||
is_in is_not_in
|
||||
is_in_day_of_week_time_ranges
|
||||
is_in_cidr is_not_in_cidr
|
||||
]a
|
||||
is
|
||||
]a
|
||||
|
||||
field :values, {:array, :string}
|
||||
end
|
||||
|
||||
@@ -15,6 +15,7 @@ defmodule Domain.Policies.Condition.Changeset do
|
||||
def valid_operators_for_property(:remote_ip), do: [:is_in_cidr, :is_not_in_cidr]
|
||||
def valid_operators_for_property(:provider_id), do: [:is_in, :is_not_in]
|
||||
def valid_operators_for_property(:current_utc_datetime), do: [:is_in_day_of_week_time_ranges]
|
||||
def valid_operators_for_property(:client_verified), do: [:is]
|
||||
|
||||
defp validate_operator(changeset) do
|
||||
case fetch_field(changeset, :property) do
|
||||
@@ -44,6 +45,13 @@ defmodule Domain.Policies.Condition.Changeset do
|
||||
validate_day_of_week_time_ranges(changeset, field)
|
||||
end)
|
||||
|
||||
{_data_or_changes, :client_verified} ->
|
||||
changeset
|
||||
|> validate_required(:operator)
|
||||
|> validate_inclusion(:operator, valid_operators_for_property(:client_verified))
|
||||
|> validate_length(:values, min: 1, max: 1)
|
||||
|> validate_list(:values, :boolean)
|
||||
|
||||
{_data_or_changes, nil} ->
|
||||
changeset
|
||||
|
||||
|
||||
@@ -124,6 +124,36 @@ defmodule Domain.Policies.Condition.Evaluator do
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_conformation_expiration(
|
||||
%Condition{
|
||||
property: :client_verified,
|
||||
operator: :is,
|
||||
values: ["true"]
|
||||
},
|
||||
%Clients.Client{verified_at: verified_at}
|
||||
) do
|
||||
if is_nil(verified_at) do
|
||||
:error
|
||||
else
|
||||
{:ok, nil}
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_conformation_expiration(
|
||||
%Condition{
|
||||
property: :client_verified,
|
||||
operator: :is,
|
||||
values: ["false"]
|
||||
},
|
||||
%Clients.Client{verified_at: verified_at}
|
||||
) do
|
||||
if is_nil(verified_at) do
|
||||
{:ok, nil}
|
||||
else
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_conformation_expiration(
|
||||
%Condition{
|
||||
property: :current_utc_datetime,
|
||||
|
||||
@@ -14,7 +14,7 @@ defmodule Domain.Policies.Policy.Changeset do
|
||||
|> cast_embed(:conditions, with: &Domain.Policies.Condition.Changeset.changeset/3)
|
||||
|> changeset()
|
||||
|> put_change(:account_id, subject.account.id)
|
||||
|> put_created_by(subject)
|
||||
|> put_subject_trail(:created_by, subject)
|
||||
end
|
||||
|
||||
def update(%Policy{} = policy, attrs) do
|
||||
|
||||
@@ -139,22 +139,22 @@ defmodule Domain.Repo.Changeset do
|
||||
end
|
||||
end
|
||||
|
||||
def put_created_by(changeset, :system) do
|
||||
def put_subject_trail(changeset, field, :system) do
|
||||
changeset
|
||||
|> put_change(:created_by, :system)
|
||||
|> put_default_value(field, :system)
|
||||
end
|
||||
|
||||
def put_created_by(changeset, %Domain.Auth.Subject{identity: nil} = subject) do
|
||||
def put_subject_trail(changeset, field, %Domain.Auth.Subject{identity: nil} = subject) do
|
||||
changeset
|
||||
|> put_change(:created_by_actor_id, subject.actor.id)
|
||||
|> put_change(:created_by, :actor)
|
||||
|> put_default_value(field, :actor)
|
||||
|> put_default_value(:"#{field}_actor_id", subject.actor.id)
|
||||
end
|
||||
|
||||
def put_created_by(changeset, %Domain.Auth.Subject{} = subject) do
|
||||
def put_subject_trail(changeset, field, %Domain.Auth.Subject{} = subject) do
|
||||
changeset
|
||||
|> put_change(:created_by, :identity)
|
||||
|> put_change(:created_by_identity_id, subject.identity.id)
|
||||
|> put_change(:created_by_actor_id, subject.actor.id)
|
||||
|> put_default_value(field, :identity)
|
||||
|> put_default_value(:"#{field}_actor_id", subject.actor.id)
|
||||
|> put_default_value(:"#{field}_identity_id", subject.identity.id)
|
||||
end
|
||||
|
||||
# Validations
|
||||
|
||||
@@ -7,7 +7,7 @@ defmodule Domain.Resources.Connection.Changeset do
|
||||
|
||||
def changeset(account_id, connection, attrs, %Auth.Subject{} = subject) do
|
||||
changeset(account_id, connection, attrs)
|
||||
|> put_created_by(subject)
|
||||
|> put_subject_trail(:created_by, subject)
|
||||
end
|
||||
|
||||
def changeset(account_id, connection, attrs) do
|
||||
|
||||
@@ -41,7 +41,7 @@ defmodule Domain.Resources.Resource.Changeset do
|
||||
with: &Connection.Changeset.changeset(account.id, &1, &2, subject),
|
||||
required: true
|
||||
)
|
||||
|> put_created_by(subject)
|
||||
|> put_subject_trail(:created_by, subject)
|
||||
end
|
||||
|
||||
def create(%Accounts.Account{} = account, attrs) do
|
||||
|
||||
@@ -46,7 +46,7 @@ defmodule Domain.Tokens.Token.Changeset do
|
||||
:service_account_client
|
||||
])
|
||||
|> changeset()
|
||||
|> put_created_by(subject)
|
||||
|> put_subject_trail(:created_by, subject)
|
||||
end
|
||||
|
||||
defp changeset(changeset) do
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
defmodule Domain.Repo.Migrations.AddClientsVerification do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:clients) do
|
||||
add(:verified_at, :utc_datetime_usec, default: nil)
|
||||
add(:verified_by, :string)
|
||||
add(:verified_by_actor_id, references(:actors, type: :binary_id))
|
||||
add(:verified_by_identity_id, references(:auth_identities, type: :binary_id))
|
||||
end
|
||||
|
||||
create(
|
||||
constraint(:clients, :verified_fields_set,
|
||||
check: """
|
||||
(
|
||||
verified_at IS NULL
|
||||
AND (verified_by IS NULL AND verified_by_actor_id IS NULL AND verified_by_identity_id IS NULL )
|
||||
)
|
||||
OR
|
||||
(
|
||||
verified_at IS NOT NULL
|
||||
AND (
|
||||
(verified_by = 'system' AND verified_by_actor_id IS NULL AND verified_by_identity_id IS NULL)
|
||||
OR (verified_by = 'actor' AND verified_by_actor_id IS NOT NULL AND verified_by_identity_id IS NULL)
|
||||
OR (verified_by = 'identity' AND verified_by_actor_id IS NOT NULL AND verified_by_identity_id IS NOT NULL)
|
||||
)
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -454,6 +454,8 @@ defmodule Domain.ClientsTest do
|
||||
assert client.last_seen_user_agent == subject.context.user_agent
|
||||
assert client.last_seen_version == "1.3.0"
|
||||
assert client.last_seen_at
|
||||
|
||||
assert is_nil(client.verified_at)
|
||||
end
|
||||
|
||||
test "updates client when it already exists", %{
|
||||
@@ -576,6 +578,7 @@ defmodule Domain.ClientsTest do
|
||||
assert client.actor_id == subject.actor.id
|
||||
assert client.account_id == account.id
|
||||
refute client.identity_id
|
||||
assert is_nil(client.verified_at)
|
||||
end
|
||||
|
||||
test "does not allow to reuse IP addresses", %{
|
||||
@@ -751,6 +754,76 @@ defmodule Domain.ClientsTest do
|
||||
end
|
||||
end
|
||||
|
||||
describe "verify_client/2" do
|
||||
test "allows admin actor to verify clients", %{admin_actor: actor, admin_subject: subject} do
|
||||
client = Fixtures.Clients.create_client(actor: actor)
|
||||
|
||||
assert {:ok, client} = verify_client(client, subject)
|
||||
assert client.verified_at
|
||||
assert client.verified_by == :identity
|
||||
assert client.verified_by_actor_id == subject.actor.id
|
||||
assert client.verified_by_identity_id == subject.identity.id
|
||||
|
||||
assert {:ok, double_verified_client} = verify_client(client, subject)
|
||||
assert double_verified_client.verified_at == client.verified_at
|
||||
end
|
||||
|
||||
test "returns error when subject has no permission to verify clients", %{
|
||||
admin_actor: actor,
|
||||
admin_subject: subject
|
||||
} do
|
||||
client = Fixtures.Clients.create_client(actor: actor)
|
||||
|
||||
subject =
|
||||
Fixtures.Auth.remove_permission(
|
||||
subject,
|
||||
Clients.Authorizer.verify_clients_permission()
|
||||
)
|
||||
|
||||
assert verify_client(client, subject) ==
|
||||
{:error,
|
||||
{:unauthorized,
|
||||
reason: :missing_permissions,
|
||||
missing_permissions: [Clients.Authorizer.verify_clients_permission()]}}
|
||||
end
|
||||
end
|
||||
|
||||
describe "remove_client_verification/2" do
|
||||
test "allows admin actor to remove client verification", %{
|
||||
admin_actor: actor,
|
||||
admin_subject: subject
|
||||
} do
|
||||
client = Fixtures.Clients.create_client(actor: actor)
|
||||
|
||||
assert {:ok, client} = verify_client(client, subject)
|
||||
assert {:ok, client} = remove_client_verification(client, subject)
|
||||
|
||||
assert is_nil(client.verified_at)
|
||||
assert is_nil(client.verified_by)
|
||||
assert is_nil(client.verified_by_actor_id)
|
||||
assert is_nil(client.verified_by_identity_id)
|
||||
end
|
||||
|
||||
test "returns error when subject has no permission to verify clients", %{
|
||||
admin_actor: actor,
|
||||
admin_subject: subject
|
||||
} do
|
||||
client = Fixtures.Clients.create_client(actor: actor)
|
||||
|
||||
subject =
|
||||
Fixtures.Auth.remove_permission(
|
||||
subject,
|
||||
Clients.Authorizer.verify_clients_permission()
|
||||
)
|
||||
|
||||
assert remove_client_verification(client, subject) ==
|
||||
{:error,
|
||||
{:unauthorized,
|
||||
reason: :missing_permissions,
|
||||
missing_permissions: [Clients.Authorizer.verify_clients_permission()]}}
|
||||
end
|
||||
end
|
||||
|
||||
describe "delete_client/2" do
|
||||
test "returns error on state conflict", %{admin_actor: actor, admin_subject: subject} do
|
||||
client = Fixtures.Clients.create_client(actor: actor)
|
||||
|
||||
@@ -180,6 +180,29 @@ defmodule Domain.Policies.Condition.EvaluatorTest do
|
||||
{:ok, nil}
|
||||
end
|
||||
|
||||
test "when client verified is required" do
|
||||
verified_client = %Domain.Clients.Client{verified_at: DateTime.utc_now()}
|
||||
not_verified_client = %Domain.Clients.Client{verified_at: nil}
|
||||
|
||||
condition = %Domain.Policies.Condition{
|
||||
property: :client_verified,
|
||||
operator: :is,
|
||||
values: ["true"]
|
||||
}
|
||||
|
||||
assert fetch_conformation_expiration(condition, verified_client) == {:ok, nil}
|
||||
assert fetch_conformation_expiration(condition, not_verified_client) == :error
|
||||
|
||||
condition = %Domain.Policies.Condition{
|
||||
property: :client_verified,
|
||||
operator: :is,
|
||||
values: ["false"]
|
||||
}
|
||||
|
||||
assert fetch_conformation_expiration(condition, verified_client) == :error
|
||||
assert fetch_conformation_expiration(condition, not_verified_client) == {:ok, nil}
|
||||
end
|
||||
|
||||
test "when client current UTC datetime is in the day of the week time ranges" do
|
||||
# this is deeply tested separately in find_day_of_the_week_time_range/2
|
||||
condition = %Domain.Policies.Condition{
|
||||
|
||||
@@ -698,6 +698,10 @@ defmodule Domain.Fixtures.Auth do
|
||||
{token, nonce <> Domain.Tokens.encode_fragment!(token)}
|
||||
end
|
||||
|
||||
def remove_permission(%Auth.Subject{} = subject, permission) do
|
||||
%{subject | permissions: MapSet.delete(subject.permissions, permission)}
|
||||
end
|
||||
|
||||
def remove_permissions(%Auth.Subject{} = subject) do
|
||||
%{subject | permissions: MapSet.new()}
|
||||
end
|
||||
|
||||
@@ -835,12 +835,13 @@ defmodule Web.CoreComponents do
|
||||
Renders online or offline status using an `online?` field of the schema.
|
||||
"""
|
||||
attr :schema, :any, required: true
|
||||
attr :class, :any, default: nil
|
||||
|
||||
def connection_status(assigns) do
|
||||
assigns = assign_new(assigns, :relative_to, fn -> DateTime.utc_now() end)
|
||||
|
||||
~H"""
|
||||
<span class="flex items-center">
|
||||
<span class={["flex items-center", @class]}>
|
||||
<.ping_icon color={if @schema.online?, do: "success", else: "danger"} />
|
||||
<span
|
||||
class="ml-2.5"
|
||||
@@ -935,6 +936,46 @@ defmodule Web.CoreComponents do
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders verification timestamp and entity.
|
||||
"""
|
||||
attr :account, :any, required: true
|
||||
attr :schema, :any, required: true
|
||||
|
||||
def verified_by(%{schema: %{verified_by: :system}} = assigns) do
|
||||
~H"""
|
||||
<.icon name="hero-shield-check" class="w-4 h-4 mr-1" /> Verified
|
||||
<.relative_datetime datetime={@schema.verified_at} /> by system
|
||||
"""
|
||||
end
|
||||
|
||||
def verified_by(%{schema: %{verified_by: :actor}} = assigns) do
|
||||
~H"""
|
||||
<.icon name="hero-shield-check" class="w-4 h-4 mr-1" /> Verified
|
||||
<.relative_datetime datetime={@schema.verified_at} /> by
|
||||
<.actor_link account={@account} actor={@schema.verified_by_actor} />
|
||||
"""
|
||||
end
|
||||
|
||||
def verified_by(%{schema: %{verified_by: :identity}} = assigns) do
|
||||
~H"""
|
||||
<.icon name="hero-shield-check" class="w-4 h-4 mr-1" /> Verified
|
||||
<.relative_datetime datetime={@schema.verified_at} /> by
|
||||
<.link
|
||||
class="text-accent-500 hover:underline"
|
||||
navigate={~p"/#{@schema.account_id}/actors/#{@schema.verified_by_identity.actor_id}"}
|
||||
>
|
||||
<%= assigns.schema.verified_by_actor.name %>
|
||||
</.link>
|
||||
"""
|
||||
end
|
||||
|
||||
def verified_by(%{schema: %{verified_at: nil}} = assigns) do
|
||||
~H"""
|
||||
Not Verified
|
||||
"""
|
||||
end
|
||||
|
||||
attr :account, :any, required: true
|
||||
attr :actor, :any, required: true
|
||||
|
||||
|
||||
@@ -13,7 +13,8 @@ defmodule Web.Clients.Index do
|
||||
|> assign_live_table("clients",
|
||||
query_module: Clients.Client.Query,
|
||||
sortable_fields: [
|
||||
{:clients, :name}
|
||||
{:clients, :name},
|
||||
{:clients, :inserted_at}
|
||||
],
|
||||
hide_filters: [
|
||||
:name
|
||||
@@ -68,6 +69,7 @@ defmodule Web.Clients.Index do
|
||||
<.link navigate={~p"/#{@account}/clients/#{client.id}"} class={[link_style()]}>
|
||||
<%= client.name %>
|
||||
</.link>
|
||||
<.icon :if={not is_nil(client.verified_at)} name="hero-shield-check" class="w-4 h-4" />
|
||||
</:col>
|
||||
<:col :let={client} label="user">
|
||||
<.link navigate={~p"/#{@account}/actors/#{client.actor.id}"} class={[link_style()]}>
|
||||
@@ -77,6 +79,9 @@ defmodule Web.Clients.Index do
|
||||
<:col :let={client} label="status">
|
||||
<.connection_status schema={client} />
|
||||
</:col>
|
||||
<:col :let={client} field={{:clients, :inserted_at}} label="created at">
|
||||
<.relative_datetime datetime={client.inserted_at} />
|
||||
</:col>
|
||||
<:empty>
|
||||
<div class="text-center text-neutral-500 p-4">
|
||||
No clients to display. Clients are created automatically when a user connects to a resource.
|
||||
|
||||
@@ -6,7 +6,13 @@ defmodule Web.Clients.Show do
|
||||
def mount(%{"id" => id}, _session, socket) do
|
||||
with {:ok, client} <-
|
||||
Clients.fetch_client_by_id(id, socket.assigns.subject,
|
||||
preload: [:online?, :actor, last_used_token: [identity: [:provider]]]
|
||||
preload: [
|
||||
:online?,
|
||||
:actor,
|
||||
:verified_by_identity,
|
||||
:verified_by_actor,
|
||||
last_used_token: [identity: [:provider]]
|
||||
]
|
||||
) do
|
||||
if connected?(socket) do
|
||||
:ok = Clients.subscribe_to_clients_presence_for_actor(client.actor)
|
||||
@@ -68,6 +74,47 @@ defmodule Web.Clients.Show do
|
||||
Client Details
|
||||
<span :if={not is_nil(@client.deleted_at)} class="text-red-600">(deleted)</span>
|
||||
</:title>
|
||||
|
||||
<:action :if={is_nil(@client.deleted_at) and not is_nil(@client.verified_at)}>
|
||||
<.button_with_confirmation
|
||||
id="remove_client_verification"
|
||||
style="danger"
|
||||
icon="hero-shield-exclamation"
|
||||
on_confirm="remove_client_verification"
|
||||
>
|
||||
<:dialog_title>Remove verification</:dialog_title>
|
||||
<:dialog_content>
|
||||
Are you sure you want to remove verification of this Client?
|
||||
</:dialog_content>
|
||||
<:dialog_confirm_button>
|
||||
Remove
|
||||
</:dialog_confirm_button>
|
||||
<:dialog_cancel_button>
|
||||
Cancel
|
||||
</:dialog_cancel_button>
|
||||
Remove verification
|
||||
</.button_with_confirmation>
|
||||
</:action>
|
||||
<:action :if={is_nil(@client.deleted_at) and is_nil(@client.verified_at)}>
|
||||
<.button_with_confirmation
|
||||
id="verify_client"
|
||||
style="warning"
|
||||
icon="hero-shield-check"
|
||||
on_confirm="verify_client"
|
||||
>
|
||||
<:dialog_title>Verify Client</:dialog_title>
|
||||
<:dialog_content>
|
||||
Are you sure you want to verify this Client?
|
||||
</:dialog_content>
|
||||
<:dialog_confirm_button>
|
||||
Verify
|
||||
</:dialog_confirm_button>
|
||||
<:dialog_cancel_button>
|
||||
Cancel
|
||||
</:dialog_cancel_button>
|
||||
Verify
|
||||
</.button_with_confirmation>
|
||||
</:action>
|
||||
<:action :if={is_nil(@client.deleted_at)}>
|
||||
<.edit_button navigate={~p"/#{@account}/clients/#{@client}/edit"}>
|
||||
Edit Client
|
||||
@@ -85,7 +132,13 @@ defmodule Web.Clients.Show do
|
||||
</.vertical_table_row>
|
||||
<.vertical_table_row>
|
||||
<:label>Status</:label>
|
||||
<:value><.connection_status schema={@client} /></:value>
|
||||
<:value><.connection_status class="ml-1/2" schema={@client} /></:value>
|
||||
</.vertical_table_row>
|
||||
<.vertical_table_row>
|
||||
<:label>Verification</:label>
|
||||
<:value>
|
||||
<.verified_by account={@account} schema={@client} />
|
||||
</:value>
|
||||
</.vertical_table_row>
|
||||
<.vertical_table_row>
|
||||
<:label>Last used sign in method</:label>
|
||||
@@ -219,7 +272,12 @@ defmodule Web.Clients.Show do
|
||||
Map.has_key?(payload.joins, client.id) ->
|
||||
{:ok, client} =
|
||||
Clients.fetch_client_by_id(client.id, socket.assigns.subject,
|
||||
preload: [:actor, last_used_token: [identity: [:provider]]]
|
||||
preload: [
|
||||
:actor,
|
||||
:verified_by_identity,
|
||||
:verified_by_actor,
|
||||
last_used_token: [identity: [:provider]]
|
||||
]
|
||||
)
|
||||
|
||||
assign(socket, client: %{client | online?: true})
|
||||
@@ -234,6 +292,33 @@ defmodule Web.Clients.Show do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("verify_client", _params, socket) do
|
||||
{:ok, client} = Clients.verify_client(socket.assigns.client, socket.assigns.subject)
|
||||
|
||||
client = %{
|
||||
client
|
||||
| online?: socket.assigns.client.online?,
|
||||
actor: socket.assigns.client.actor,
|
||||
last_used_token: socket.assigns.client.last_used_token
|
||||
}
|
||||
|
||||
{:noreply, assign(socket, :client, client)}
|
||||
end
|
||||
|
||||
def handle_event("remove_client_verification", _params, socket) do
|
||||
{:ok, client} =
|
||||
Clients.remove_client_verification(socket.assigns.client, socket.assigns.subject)
|
||||
|
||||
client = %{
|
||||
client
|
||||
| online?: socket.assigns.client.online?,
|
||||
actor: socket.assigns.client.actor,
|
||||
last_used_token: socket.assigns.client.last_used_token
|
||||
}
|
||||
|
||||
{:noreply, assign(socket, :client, client)}
|
||||
end
|
||||
|
||||
def handle_event(event, params, socket) when event in ["paginate", "order_by", "filter"],
|
||||
do: handle_live_table_event(event, params, socket)
|
||||
end
|
||||
|
||||
@@ -168,6 +168,16 @@ defmodule Web.Policies.Components do
|
||||
"""
|
||||
end
|
||||
|
||||
defp condition(%{property: :client_verified} = assigns) do
|
||||
~H"""
|
||||
<span :if={@values != []} class="mr-1">
|
||||
<span>by clients that are</span>
|
||||
<span :if={@values == ["true"]}>verified</span>
|
||||
<span :if={@values == ["false"]}>not verified</span>
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp condition(%{property: :current_utc_datetime, values: values} = assigns) do
|
||||
assigns =
|
||||
assign_new(assigns, :tz_time_ranges_by_dow, fn ->
|
||||
@@ -221,6 +231,7 @@ defmodule Web.Policies.Components do
|
||||
defp condition_operator_option_name(:contains), do: "contains"
|
||||
defp condition_operator_option_name(:does_not_contain), do: "does not contain"
|
||||
defp condition_operator_option_name(:is_in), do: "is in"
|
||||
defp condition_operator_option_name(:is), do: "is"
|
||||
defp condition_operator_option_name(:is_not_in), do: "is not in"
|
||||
defp condition_operator_option_name(:is_in_day_of_week_time_ranges), do: ""
|
||||
defp condition_operator_option_name(:is_in_cidr), do: "is in"
|
||||
@@ -261,6 +272,7 @@ defmodule Web.Policies.Components do
|
||||
providers={@providers}
|
||||
disabled={@policy_conditions_enabled? == false}
|
||||
/>
|
||||
<.client_verified_condition_form form={@form} disabled={@policy_conditions_enabled? == false} />
|
||||
<.current_utc_datetime_condition_form
|
||||
form={@form}
|
||||
timezone={@timezone}
|
||||
@@ -528,6 +540,83 @@ defmodule Web.Policies.Components do
|
||||
"""
|
||||
end
|
||||
|
||||
defp client_verified_condition_form(assigns) do
|
||||
~H"""
|
||||
<fieldset class="mb-4">
|
||||
<% condition_form = find_condition_form(@form[:conditions], :client_verified) %>
|
||||
|
||||
<.input
|
||||
type="hidden"
|
||||
field={condition_form[:property]}
|
||||
name="policy[conditions][client_verified][property]"
|
||||
id="policy_conditions_client_verified_property"
|
||||
value="client_verified"
|
||||
/>
|
||||
|
||||
<.input
|
||||
type="hidden"
|
||||
name="policy[conditions][client_verified][operator]"
|
||||
id="policy_conditions_client_verified_operator"
|
||||
field={condition_form[:operator]}
|
||||
value={:is}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="hover:bg-neutral-100 cursor-pointer border border-neutral-200 shadow-b rounded-t px-4 py-2"
|
||||
phx-click={
|
||||
JS.toggle_class("hidden",
|
||||
to: "#policy_conditions_client_verified_condition"
|
||||
)
|
||||
|> JS.toggle_class("bg-neutral-50")
|
||||
|> JS.toggle_class("hero-chevron-down",
|
||||
to: "#policy_conditions_client_verified_chevron"
|
||||
)
|
||||
|> JS.toggle_class("hero-chevron-up",
|
||||
to: "#policy_conditions_client_verified_chevron"
|
||||
)
|
||||
}
|
||||
>
|
||||
<legend class="flex justify-between items-center text-neutral-700">
|
||||
<span class="flex items-center">
|
||||
<.icon name="hero-shield-check" class="w-5 h-5 mr-2" /> Client verification
|
||||
</span>
|
||||
<span class="shadow bg-white w-6 h-6 flex items-center justify-center rounded-full">
|
||||
<.icon
|
||||
id="policy_conditions_client_verified_chevron"
|
||||
name="hero-chevron-down"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
</span>
|
||||
</legend>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="policy_conditions_client_verified_condition"
|
||||
class={[
|
||||
"p-4 border-neutral-200 border-l border-r border-b rounded-b",
|
||||
condition_values_empty?(condition_form) && "hidden"
|
||||
]}
|
||||
>
|
||||
<p class="text-sm text-neutral-500 mb-4">
|
||||
Allow access when the Client is manually verified by the administrator.
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
<.input
|
||||
type="checkbox"
|
||||
label="Require client verification"
|
||||
field={condition_form[:values]}
|
||||
name="policy[conditions][client_verified][values][]"
|
||||
id="policy_conditions_client_verified_value"
|
||||
disabled={@disabled}
|
||||
checked={List.first(List.wrap(condition_form[:values].value)) == "true"}
|
||||
value="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
"""
|
||||
end
|
||||
|
||||
defp current_utc_datetime_condition_form(assigns) do
|
||||
assigns = assign_new(assigns, :days_of_week, fn -> @days_of_week end)
|
||||
|
||||
|
||||
@@ -95,6 +95,7 @@ defmodule Web.Live.Clients.ShowTest do
|
||||
assert table["last seen remote ip"] =~ to_string(client.last_seen_remote_ip)
|
||||
assert table["client version"] =~ client.last_seen_version
|
||||
assert table["user agent"] =~ client.last_seen_user_agent
|
||||
assert table["verification"] =~ "Not Verified"
|
||||
end
|
||||
|
||||
test "shows client online status", %{
|
||||
@@ -291,4 +292,30 @@ defmodule Web.Live.Clients.ShowTest do
|
||||
{:error,
|
||||
{:live_redirect, %{to: ~p"/#{account}/clients/#{client}/edit", kind: :push}}}
|
||||
end
|
||||
|
||||
test "allows verifying clients", %{
|
||||
account: account,
|
||||
client: client,
|
||||
identity: identity,
|
||||
conn: conn
|
||||
} do
|
||||
{:ok, lv, _html} =
|
||||
conn
|
||||
|> authorize_conn(identity)
|
||||
|> live(~p"/#{account}/clients/#{client}")
|
||||
|
||||
assert lv
|
||||
|> element("button[type=submit]", "Verify")
|
||||
|> render_click()
|
||||
|
||||
table =
|
||||
lv
|
||||
|> element("#client")
|
||||
|> render()
|
||||
|> vertical_table_to_map()
|
||||
|
||||
refute table["verification"] =~ "Not"
|
||||
assert table["verification"] =~ "Verified"
|
||||
assert table["verification"] =~ "by"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -60,6 +60,9 @@ defmodule Web.Live.Policies.NewTest do
|
||||
|
||||
assert find_inputs(form) == [
|
||||
"policy[actor_group_id]",
|
||||
"policy[conditions][client_verified][operator]",
|
||||
"policy[conditions][client_verified][property]",
|
||||
"policy[conditions][client_verified][values][]",
|
||||
"policy[conditions][current_utc_datetime][operator]",
|
||||
"policy[conditions][current_utc_datetime][property]",
|
||||
"policy[conditions][current_utc_datetime][timezone]",
|
||||
@@ -99,6 +102,9 @@ defmodule Web.Live.Policies.NewTest do
|
||||
|
||||
assert find_inputs(form) == [
|
||||
"policy[actor_group_id]",
|
||||
"policy[conditions][client_verified][operator]",
|
||||
"policy[conditions][client_verified][property]",
|
||||
"policy[conditions][client_verified][values][]",
|
||||
"policy[conditions][current_utc_datetime][operator]",
|
||||
"policy[conditions][current_utc_datetime][property]",
|
||||
"policy[conditions][current_utc_datetime][timezone]",
|
||||
@@ -149,6 +155,9 @@ defmodule Web.Live.Policies.NewTest do
|
||||
|
||||
assert find_inputs(form) == [
|
||||
"policy[actor_group_id]",
|
||||
"policy[conditions][client_verified][operator]",
|
||||
"policy[conditions][client_verified][property]",
|
||||
"policy[conditions][client_verified][values][]",
|
||||
"policy[conditions][current_utc_datetime][operator]",
|
||||
"policy[conditions][current_utc_datetime][property]",
|
||||
"policy[conditions][current_utc_datetime][timezone]",
|
||||
@@ -329,6 +338,11 @@ defmodule Web.Live.Policies.NewTest do
|
||||
assert policy.resource_id == resource.id
|
||||
|
||||
assert policy.conditions == [
|
||||
%Domain.Policies.Condition{
|
||||
property: :client_verified,
|
||||
operator: :is,
|
||||
values: nil
|
||||
},
|
||||
%Domain.Policies.Condition{
|
||||
property: :current_utc_datetime,
|
||||
operator: :is_in_day_of_week_time_ranges,
|
||||
|
||||
Reference in New Issue
Block a user