diff --git a/elixir/apps/domain/lib/domain/actors/group/changeset.ex b/elixir/apps/domain/lib/domain/actors/group/changeset.ex index 7cfe677d0..1b5c64e22 100644 --- a/elixir/apps/domain/lib/domain/actors/group/changeset.ex +++ b/elixir/apps/domain/lib/domain/actors/group/changeset.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/clients.ex b/elixir/apps/domain/lib/domain/clients.ex index e488bea80..72e9a0b2c 100644 --- a/elixir/apps/domain/lib/domain/clients.ex +++ b/elixir/apps/domain/lib/domain/clients.ex @@ -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() diff --git a/elixir/apps/domain/lib/domain/clients/authorizer.ex b/elixir/apps/domain/lib/domain/clients/authorizer.ex index c1c77489b..339b42fc4 100644 --- a/elixir/apps/domain/lib/domain/clients/authorizer.ex +++ b/elixir/apps/domain/lib/domain/clients/authorizer.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/clients/client.ex b/elixir/apps/domain/lib/domain/clients/client.ex index 1185e5b42..6cb5d0072 100644 --- a/elixir/apps/domain/lib/domain/clients/client.ex +++ b/elixir/apps/domain/lib/domain/clients/client.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/clients/client/changeset.ex b/elixir/apps/domain/lib/domain/clients/client/changeset.ex index db74e0778..7fef5758c 100644 --- a/elixir/apps/domain/lib/domain/clients/client/changeset.ex +++ b/elixir/apps/domain/lib/domain/clients/client/changeset.ex @@ -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) diff --git a/elixir/apps/domain/lib/domain/clients/client/query.ex b/elixir/apps/domain/lib/domain/clients/client/query.ex index ba3d65f90..2ba60b51f 100644 --- a/elixir/apps/domain/lib/domain/clients/client/query.ex +++ b/elixir/apps/domain/lib/domain/clients/client/query.ex @@ -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 -> diff --git a/elixir/apps/domain/lib/domain/gateways/group/changeset.ex b/elixir/apps/domain/lib/domain/gateways/group/changeset.ex index 7c895cc9c..2b4dc07cd 100644 --- a/elixir/apps/domain/lib/domain/gateways/group/changeset.ex +++ b/elixir/apps/domain/lib/domain/gateways/group/changeset.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/policies/condition.ex b/elixir/apps/domain/lib/domain/policies/condition.ex index 64cbef5f3..ff78fa90e 100644 --- a/elixir/apps/domain/lib/domain/policies/condition.ex +++ b/elixir/apps/domain/lib/domain/policies/condition.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/policies/condition/changeset.ex b/elixir/apps/domain/lib/domain/policies/condition/changeset.ex index 43457ce1c..8bc58fa7f 100644 --- a/elixir/apps/domain/lib/domain/policies/condition/changeset.ex +++ b/elixir/apps/domain/lib/domain/policies/condition/changeset.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/policies/condition/evaluator.ex b/elixir/apps/domain/lib/domain/policies/condition/evaluator.ex index 0b59fdeda..de3753d4d 100644 --- a/elixir/apps/domain/lib/domain/policies/condition/evaluator.ex +++ b/elixir/apps/domain/lib/domain/policies/condition/evaluator.ex @@ -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, diff --git a/elixir/apps/domain/lib/domain/policies/policy/changeset.ex b/elixir/apps/domain/lib/domain/policies/policy/changeset.ex index b5c5f867f..c09d2dc25 100644 --- a/elixir/apps/domain/lib/domain/policies/policy/changeset.ex +++ b/elixir/apps/domain/lib/domain/policies/policy/changeset.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/repo/changeset.ex b/elixir/apps/domain/lib/domain/repo/changeset.ex index 46c09f104..1887ff9a7 100644 --- a/elixir/apps/domain/lib/domain/repo/changeset.ex +++ b/elixir/apps/domain/lib/domain/repo/changeset.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/resources/connection/changeset.ex b/elixir/apps/domain/lib/domain/resources/connection/changeset.ex index 89a0504c3..d902589ff 100644 --- a/elixir/apps/domain/lib/domain/resources/connection/changeset.ex +++ b/elixir/apps/domain/lib/domain/resources/connection/changeset.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/resources/resource/changeset.ex b/elixir/apps/domain/lib/domain/resources/resource/changeset.ex index b140193bc..b04d3baf1 100644 --- a/elixir/apps/domain/lib/domain/resources/resource/changeset.ex +++ b/elixir/apps/domain/lib/domain/resources/resource/changeset.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/tokens/token/changeset.ex b/elixir/apps/domain/lib/domain/tokens/token/changeset.ex index da2eea8b3..e386e706b 100644 --- a/elixir/apps/domain/lib/domain/tokens/token/changeset.ex +++ b/elixir/apps/domain/lib/domain/tokens/token/changeset.ex @@ -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 diff --git a/elixir/apps/domain/priv/repo/migrations/20240905180909_add_clients_verification.exs b/elixir/apps/domain/priv/repo/migrations/20240905180909_add_clients_verification.exs new file mode 100644 index 000000000..8fa20f5d2 --- /dev/null +++ b/elixir/apps/domain/priv/repo/migrations/20240905180909_add_clients_verification.exs @@ -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 diff --git a/elixir/apps/domain/test/domain/clients_test.exs b/elixir/apps/domain/test/domain/clients_test.exs index 1eab1fa35..e5b59d864 100644 --- a/elixir/apps/domain/test/domain/clients_test.exs +++ b/elixir/apps/domain/test/domain/clients_test.exs @@ -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) diff --git a/elixir/apps/domain/test/domain/policies/condition/evaluator_test.exs b/elixir/apps/domain/test/domain/policies/condition/evaluator_test.exs index c218379df..41f7401b3 100644 --- a/elixir/apps/domain/test/domain/policies/condition/evaluator_test.exs +++ b/elixir/apps/domain/test/domain/policies/condition/evaluator_test.exs @@ -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{ diff --git a/elixir/apps/domain/test/support/fixtures/auth.ex b/elixir/apps/domain/test/support/fixtures/auth.ex index 8f47359de..33f5c964a 100644 --- a/elixir/apps/domain/test/support/fixtures/auth.ex +++ b/elixir/apps/domain/test/support/fixtures/auth.ex @@ -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 diff --git a/elixir/apps/web/lib/web/components/core_components.ex b/elixir/apps/web/lib/web/components/core_components.ex index f8df6abb4..0aa76e08c 100644 --- a/elixir/apps/web/lib/web/components/core_components.ex +++ b/elixir/apps/web/lib/web/components/core_components.ex @@ -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""" - + <.ping_icon color={if @schema.online?, do: "success", else: "danger"} /> 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 %> + + """ + end + + def verified_by(%{schema: %{verified_at: nil}} = assigns) do + ~H""" + Not Verified + """ + end + attr :account, :any, required: true attr :actor, :any, required: true diff --git a/elixir/apps/web/lib/web/live/clients/index.ex b/elixir/apps/web/lib/web/live/clients/index.ex index f70fa2126..ad524a599 100644 --- a/elixir/apps/web/lib/web/live/clients/index.ex +++ b/elixir/apps/web/lib/web/live/clients/index.ex @@ -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 %> + <.icon :if={not is_nil(client.verified_at)} name="hero-shield-check" class="w-4 h-4" /> <: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 :let={client} field={{:clients, :inserted_at}} label="created at"> + <.relative_datetime datetime={client.inserted_at} /> + <:empty>
No clients to display. Clients are created automatically when a user connects to a resource. diff --git a/elixir/apps/web/lib/web/live/clients/show.ex b/elixir/apps/web/lib/web/live/clients/show.ex index ea982ea7d..16de929df 100644 --- a/elixir/apps/web/lib/web/live/clients/show.ex +++ b/elixir/apps/web/lib/web/live/clients/show.ex @@ -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 (deleted) + + <: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_content> + Are you sure you want to remove verification of this Client? + + <:dialog_confirm_button> + Remove + + <:dialog_cancel_button> + Cancel + + Remove verification + + + <: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_content> + Are you sure you want to verify this Client? + + <:dialog_confirm_button> + Verify + + <:dialog_cancel_button> + Cancel + + Verify + + <: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> <:label>Status - <:value><.connection_status schema={@client} /> + <:value><.connection_status class="ml-1/2" schema={@client} /> + + <.vertical_table_row> + <:label>Verification + <:value> + <.verified_by account={@account} schema={@client} /> + <.vertical_table_row> <:label>Last used sign in method @@ -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 diff --git a/elixir/apps/web/lib/web/live/policies/components.ex b/elixir/apps/web/lib/web/live/policies/components.ex index e9117f490..70b8aa2df 100644 --- a/elixir/apps/web/lib/web/live/policies/components.ex +++ b/elixir/apps/web/lib/web/live/policies/components.ex @@ -168,6 +168,16 @@ defmodule Web.Policies.Components do """ end + defp condition(%{property: :client_verified} = assigns) do + ~H""" + + by clients that are + verified + not verified + + """ + 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""" +
+ <% 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} + /> + +
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" + ) + } + > + + + <.icon name="hero-shield-check" class="w-5 h-5 mr-2" /> Client verification + + + <.icon + id="policy_conditions_client_verified_chevron" + name="hero-chevron-down" + class="w-5 h-5" + /> + + +
+ +
+

+ Allow access when the Client is manually verified by the administrator. +

+
+ <.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" + /> +
+
+
+ """ + end + defp current_utc_datetime_condition_form(assigns) do assigns = assign_new(assigns, :days_of_week, fn -> @days_of_week end) diff --git a/elixir/apps/web/test/web/live/clients/show_test.exs b/elixir/apps/web/test/web/live/clients/show_test.exs index 09dd29aed..5b81e43d9 100644 --- a/elixir/apps/web/test/web/live/clients/show_test.exs +++ b/elixir/apps/web/test/web/live/clients/show_test.exs @@ -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 diff --git a/elixir/apps/web/test/web/live/policies/new_test.exs b/elixir/apps/web/test/web/live/policies/new_test.exs index f98246aec..dff6720aa 100644 --- a/elixir/apps/web/test/web/live/policies/new_test.exs +++ b/elixir/apps/web/test/web/live/policies/new_test.exs @@ -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,