From 06394ab3c91c4c8d074a2455406293cae02151c5 Mon Sep 17 00:00:00 2001 From: bmanifold Date: Tue, 8 Aug 2023 14:55:53 -0500 Subject: [PATCH] Add policies (#1850) Why: * Policies are needed to make sure devices are allowed to connect to a given resource. --------- Signed-off-by: bmanifold Co-authored-by: Andrew Dryga --- elixir/apps/domain/lib/domain/auth/roles.ex | 5 +- elixir/apps/domain/lib/domain/policies.ex | 90 +++++ .../domain/lib/domain/policies/authorizer.ex | 37 ++ .../apps/domain/lib/domain/policies/policy.ex | 17 + .../lib/domain/policies/policy/changeset.ex | 58 +++ .../lib/domain/policies/policy/query.ex | 25 ++ .../20230801154306_create_policies.exs | 44 +++ elixir/apps/domain/priv/repo/seeds.exs | 119 +++++- .../apps/domain/test/domain/policies_test.exs | 374 ++++++++++++++++++ .../support/fixtures/policies_fixtures.ex | 61 +++ elixir/apps/web/lib/web/live/policies/edit.ex | 31 +- .../apps/web/lib/web/live/policies/index.ex | 229 ++++------- elixir/apps/web/lib/web/live/policies/new.ex | 81 ++-- elixir/apps/web/lib/web/live/policies/show.ex | 122 +++--- 14 files changed, 1019 insertions(+), 274 deletions(-) create mode 100644 elixir/apps/domain/lib/domain/policies.ex create mode 100644 elixir/apps/domain/lib/domain/policies/authorizer.ex create mode 100644 elixir/apps/domain/lib/domain/policies/policy.ex create mode 100644 elixir/apps/domain/lib/domain/policies/policy/changeset.ex create mode 100644 elixir/apps/domain/lib/domain/policies/policy/query.ex create mode 100644 elixir/apps/domain/priv/repo/migrations/20230801154306_create_policies.exs create mode 100644 elixir/apps/domain/test/domain/policies_test.exs create mode 100644 elixir/apps/domain/test/support/fixtures/policies_fixtures.ex diff --git a/elixir/apps/domain/lib/domain/auth/roles.ex b/elixir/apps/domain/lib/domain/auth/roles.ex index 10613a791..25a431d22 100644 --- a/elixir/apps/domain/lib/domain/auth/roles.ex +++ b/elixir/apps/domain/lib/domain/auth/roles.ex @@ -10,13 +10,14 @@ defmodule Domain.Auth.Roles do defp list_authorizers do [ + Domain.Accounts.Authorizer, + Domain.Actors.Authorizer, Domain.Auth.Authorizer, Domain.Config.Authorizer, - Domain.Accounts.Authorizer, Domain.Devices.Authorizer, Domain.Gateways.Authorizer, + Domain.Policies.Authorizer, Domain.Relays.Authorizer, - Domain.Actors.Authorizer, Domain.Resources.Authorizer ] end diff --git a/elixir/apps/domain/lib/domain/policies.ex b/elixir/apps/domain/lib/domain/policies.ex new file mode 100644 index 000000000..74aa90a16 --- /dev/null +++ b/elixir/apps/domain/lib/domain/policies.ex @@ -0,0 +1,90 @@ +defmodule Domain.Policies do + alias Domain.{Auth, Repo, Validator} + alias Domain.Policy + alias Domain.Policies.{Authorizer, Policy} + + def fetch_policy_by_id(id, %Auth.Subject{} = subject, opts \\ []) do + {preload, _opts} = Keyword.pop(opts, :preload, []) + + required_permissions = + {:one_of, + [ + Authorizer.manage_policies_permission(), + Authorizer.view_available_policies_permission() + ]} + + with :ok <- Auth.ensure_has_permissions(subject, required_permissions), + true <- Validator.valid_uuid?(id) do + Policy.Query.by_id(id) + |> Authorizer.for_subject(subject) + |> Repo.fetch() + |> case do + {:ok, policy} -> {:ok, Repo.preload(policy, preload)} + {:error, reason} -> {:error, reason} + end + else + false -> {:error, :not_found} + other -> other + end + end + + def list_policies(%Auth.Subject{} = subject, opts \\ []) do + {preload, _opts} = Keyword.pop(opts, :preload, []) + + required_permissions = + {:one_of, + [ + Authorizer.manage_policies_permission(), + Authorizer.view_available_policies_permission() + ]} + + with :ok <- Auth.ensure_has_permissions(subject, required_permissions) do + {:ok, policies} = + Policy.Query.all() + |> Authorizer.for_subject(subject) + |> Repo.list() + + {:ok, Repo.preload(policies, preload)} + end + end + + def create_policy(attrs, %Auth.Subject{} = subject) do + required_permissions = + {:one_of, [Authorizer.manage_policies_permission()]} + + with :ok <- Auth.ensure_has_permissions(subject, required_permissions) do + Policy.Changeset.create_changeset(attrs, subject) + |> Repo.insert() + end + end + + def update_policy(%Policy{} = policy, attrs, %Auth.Subject{} = subject) do + required_permissions = + {:one_of, [Authorizer.manage_policies_permission()]} + + with :ok <- Auth.ensure_has_permissions(subject, required_permissions), + :ok <- ensure_has_access_to(subject, policy) do + Policy.Changeset.update_changeset(policy, attrs) + |> Repo.update() + end + end + + def delete_policy(%Policy{} = policy, %Auth.Subject{} = subject) do + required_permissions = + {:one_of, [Authorizer.manage_policies_permission()]} + + with :ok <- Auth.ensure_has_permissions(subject, required_permissions) do + Policy.Query.by_id(policy.id) + |> Authorizer.for_subject(subject) + |> Repo.fetch_and_update(with: &Policy.Changeset.delete_changeset/1) + end + end + + def ensure_has_access_to(%Auth.Subject{} = subject, %Policy{} = policy) do + if subject.account.id == policy.account_id do + :ok + else + {:error, :unauthorized} + end + end +end diff --git a/elixir/apps/domain/lib/domain/policies/authorizer.ex b/elixir/apps/domain/lib/domain/policies/authorizer.ex new file mode 100644 index 000000000..7cc463f58 --- /dev/null +++ b/elixir/apps/domain/lib/domain/policies/authorizer.ex @@ -0,0 +1,37 @@ +defmodule Domain.Policies.Authorizer do + use Domain.Auth.Authorizer + alias Domain.Policies.Policy + + def manage_policies_permission, do: build(Policy, :manage) + def view_available_policies_permission, do: build(Policy, :view_available_policies) + + @impl Domain.Auth.Authorizer + def list_permissions_for_role(:account_admin_user) do + [ + manage_policies_permission() + ] + end + + def list_permissions_for_role(:account_user) do + [ + view_available_policies_permission() + ] + end + + def list_permissions_for_role(_) do + [] + end + + @impl Domain.Auth.Authorizer + def for_subject(queryable, %Subject{} = subject) do + cond do + has_permission?(subject, manage_policies_permission()) -> + Policy.Query.by_account_id(queryable, subject.account.id) + + has_permission?(subject, view_available_policies_permission()) -> + queryable + |> Policy.Query.by_account_id(subject.account.id) + |> Policy.Query.by_actor_id(subject.actor.id) + end + end +end diff --git a/elixir/apps/domain/lib/domain/policies/policy.ex b/elixir/apps/domain/lib/domain/policies/policy.ex new file mode 100644 index 000000000..d99c330bc --- /dev/null +++ b/elixir/apps/domain/lib/domain/policies/policy.ex @@ -0,0 +1,17 @@ +defmodule Domain.Policies.Policy do + use Domain, :schema + + schema "policies" do + field :name, :string + + belongs_to :actor_group, Domain.Actors.Group + belongs_to :resource, Domain.Resources.Resource + belongs_to :account, Domain.Accounts.Account + + field :created_by, Ecto.Enum, values: ~w[identity]a + belongs_to :created_by_identity, Domain.Auth.Identity + + field :deleted_at, :utc_datetime_usec + timestamps() + end +end diff --git a/elixir/apps/domain/lib/domain/policies/policy/changeset.ex b/elixir/apps/domain/lib/domain/policies/policy/changeset.ex new file mode 100644 index 000000000..7a9d922fe --- /dev/null +++ b/elixir/apps/domain/lib/domain/policies/policy/changeset.ex @@ -0,0 +1,58 @@ +defmodule Domain.Policies.Policy.Changeset do + use Domain, :changeset + alias Domain.Auth + alias Domain.Policies.Policy + + @fields ~w[name actor_group_id resource_id]a + @update_fields ~w[name]a + @required_fields @fields + + def create_changeset(attrs, %Auth.Subject{} = subject) do + %Policy{} + |> cast(attrs, @fields) + |> validate_required(@required_fields) + |> changeset() + |> put_change(:account_id, subject.account.id) + |> put_change(:created_by, :identity) + |> put_change(:created_by_identity_id, subject.identity.id) + end + + def update_changeset(%Policy{} = policy, attrs) do + policy + |> cast(attrs, @update_fields) + |> validate_required(@required_fields) + |> changeset() + end + + def delete_changeset(%Policy{} = policy) do + policy + |> change() + |> put_change(:deleted_at, DateTime.utc_now()) + end + + defp changeset(changeset) do + changeset + |> validate_length(:name, min: 1, max: 255) + |> unique_constraint([:account_id, :name], + message: "Policy Name already exists", + error_key: :name + ) + |> unique_constraint( + :base, + name: :policies_account_id_resource_id_actor_group_id_index, + message: "Policy with Group and Resource already exists" + ) + |> assoc_constraint(:resource) + |> assoc_constraint(:actor_group) + |> unique_constraint( + :base, + name: :policies_actor_group_id_fkey, + message: "Not allowed to create policies for groups outside of your account" + ) + |> unique_constraint( + :base, + name: :policies_resource_id_fkey, + message: "Not allowed to create policies for resources outside of your account" + ) + end +end diff --git a/elixir/apps/domain/lib/domain/policies/policy/query.ex b/elixir/apps/domain/lib/domain/policies/policy/query.ex new file mode 100644 index 000000000..c6e85df8c --- /dev/null +++ b/elixir/apps/domain/lib/domain/policies/policy/query.ex @@ -0,0 +1,25 @@ +defmodule Domain.Policies.Policy.Query do + use Domain, :query + + def all do + from(policies in Domain.Policies.Policy, as: :policies) + |> where([policies: policies], is_nil(policies.deleted_at)) + end + + def by_id(queryable \\ all(), id) do + where(queryable, [policies: policies], policies.id == ^id) + end + + def by_account_id(queryable \\ all(), account_id) do + where(queryable, [policies: policies], policies.account_id == ^account_id) + end + + def by_actor_id(queryable \\ all(), actor_id) do + queryable + |> join(:inner, [policies: policies], ag in assoc(policies, :actor_group), as: :actor_groups) + |> join(:inner, [actor_groups: actor_groups], a in assoc(actor_groups, :memberships), + as: :memberships + ) + |> where([memberships: memberships], memberships.actor_id == ^actor_id) + end +end diff --git a/elixir/apps/domain/priv/repo/migrations/20230801154306_create_policies.exs b/elixir/apps/domain/priv/repo/migrations/20230801154306_create_policies.exs new file mode 100644 index 000000000..22d9ae995 --- /dev/null +++ b/elixir/apps/domain/priv/repo/migrations/20230801154306_create_policies.exs @@ -0,0 +1,44 @@ +defmodule Domain.Repo.Migrations.CreatePolicies do + use Ecto.Migration + + def change do + create(index(:resources, [:account_id, :id], unique: true)) + create(index(:actor_groups, [:account_id, :id], unique: true)) + + create table(:policies, primary_key: false) do + add(:id, :uuid, primary_key: true) + add(:name, :string, null: false) + + add( + :actor_group_id, + references(:actor_groups, + type: :binary_id, + on_delete: :delete_all, + with: [account_id: :account_id] + ), + null: false + ) + + add( + :resource_id, + references(:resources, + type: :binary_id, + on_delete: :delete_all, + with: [account_id: :account_id] + ), + null: false + ) + + add(:account_id, references(:accounts, type: :binary_id), null: false) + + add(:created_by, :string, null: false) + add(:created_by_identity_id, references(:auth_identities, type: :binary_id)) + + add(:deleted_at, :utc_datetime_usec) + timestamps(type: :utc_datetime_usec) + end + + create(index(:policies, [:account_id, :name], unique: true)) + create(index(:policies, [:account_id, :resource_id, :actor_group_id], unique: true)) + end +end diff --git a/elixir/apps/domain/priv/repo/seeds.exs b/elixir/apps/domain/priv/repo/seeds.exs index 076240589..a4e39eb99 100644 --- a/elixir/apps/domain/priv/repo/seeds.exs +++ b/elixir/apps/domain/priv/repo/seeds.exs @@ -1,4 +1,4 @@ -alias Domain.{Repo, Accounts, Auth, Actors, Relays, Gateways, Resources} +alias Domain.{Repo, Accounts, Auth, Actors, Relays, Gateways, Resources, Policies} # Seeds can be run both with MIX_ENV=prod and MIX_ENV=test, for test env we don't have # an adapter configured and creation of email provider will fail, so we will override it here. @@ -56,6 +56,20 @@ IO.puts("") adapter_config: %{} }) +{:ok, other_email_provider} = + Auth.create_provider(other_account, %{ + name: "email", + adapter: :email, + adapter_config: %{} + }) + +{:ok, other_userpass_provider} = + Auth.create_provider(other_account, %{ + name: "UserPass", + adapter: :userpass, + adapter_config: %{} + }) + unprivileged_actor_email = "firezone-unprivileged-1@localhost" admin_actor_email = "firezone@localhost" @@ -88,6 +102,39 @@ unprivileged_actor_userpass_identity = id: "7da7d1cd-111c-44a7-b5ac-4027b9d230e5" ) +# Other Account Users +other_unprivileged_actor_email = "other-unprivileged-1@localhost" +other_admin_actor_email = "other@localhost" + +{:ok, other_unprivileged_actor} = + Actors.create_actor(other_email_provider, other_unprivileged_actor_email, %{ + type: :account_user, + name: "Other Unprivileged" + }) + +{:ok, other_admin_actor} = + Actors.create_actor(other_email_provider, other_admin_actor_email, %{ + type: :account_admin_user, + name: "Other Admin" + }) + +{:ok, other_unprivileged_actor_userpass_identity} = + Auth.create_identity( + other_unprivileged_actor, + other_userpass_provider, + other_unprivileged_actor_email, + %{ + "password" => "Firezone1234", + "password_confirmation" => "Firezone1234" + } + ) + +{:ok, _other_admin_actor_userpass_identity} = + Auth.create_identity(other_admin_actor, other_userpass_provider, other_admin_actor_email, %{ + "password" => "Firezone1234", + "password_confirmation" => "Firezone1234" + }) + if client_secret = System.get_env("SEEDS_GOOGLE_OIDC_CLIENT_SECRET") do {:ok, google_provider} = Auth.create_provider(account, %{ @@ -171,6 +218,41 @@ admin_iphone = IO.puts("Devices created") IO.puts("") +IO.puts("Created Actor Groups: ") + +{:ok, eng_group} = Actors.create_group(%{name: "Engineering"}, admin_subject) +{:ok, finance_group} = Actors.create_group(%{name: "Finance"}, admin_subject) +{:ok, all_group} = Actors.create_group(%{name: "All Employees"}, admin_subject) + +for group <- [eng_group, finance_group, all_group] do + IO.puts(" Name: #{group.name} ID: #{group.id}") +end + +Actors.update_group( + eng_group, + %{memberships: [%{actor_id: admin_subject.actor.id}]}, + admin_subject +) + +Actors.update_group( + finance_group, + %{memberships: [%{actor_id: unprivileged_subject.actor.id}]}, + admin_subject +) + +Actors.update_group( + all_group, + %{ + memberships: [ + %{actor_id: admin_subject.actor.id}, + %{actor_id: unprivileged_subject.actor.id} + ] + }, + admin_subject +) + +IO.puts("") + relay_group = account |> Relays.Group.Changeset.create_changeset( @@ -262,7 +344,7 @@ IO.puts(" Public Key: #{gateway2.public_key}") IO.puts(" IPv4: #{gateway2.ipv4} IPv6: #{gateway2.ipv6}") IO.puts("") -{:ok, dns_resource} = +{:ok, dns_google_resource} = Resources.create_resource( %{ type: :dns, @@ -272,7 +354,7 @@ IO.puts("") admin_subject ) -{:ok, dns_resource} = +{:ok, dns_gitlab_resource} = Resources.create_resource( %{ type: :dns, @@ -299,10 +381,39 @@ IO.puts("") ) IO.puts("Created resources:") -IO.puts(" #{dns_resource.address} - DNS - #{dns_resource.ipv4} - gateways: #{gateway_name}") + +IO.puts( + " #{dns_google_resource.address} - DNS - #{dns_google_resource.ipv4} - gateways: #{gateway_name}" +) + +IO.puts( + " #{dns_gitlab_resource.address} - DNS - #{dns_gitlab_resource.ipv4} - gateways: #{gateway_name}" +) + IO.puts(" #{cidr_resource.address} - CIDR - gateways: #{gateway_name}") IO.puts("") +Policies.create_policy( + %{ + name: "Eng Access To Gitlab", + actor_group_id: eng_group.id, + resource_id: dns_gitlab_resource.id + }, + admin_subject +) + +Policies.create_policy( + %{ + name: "All Access To Network", + actor_group_id: all_group.id, + resource_id: cidr_resource.id + }, + admin_subject +) + +IO.puts("Policies Created") +IO.puts("") + {:ok, unprivileged_subject_session_token} = Auth.create_session_token_from_subject(unprivileged_subject) diff --git a/elixir/apps/domain/test/domain/policies_test.exs b/elixir/apps/domain/test/domain/policies_test.exs new file mode 100644 index 000000000..54f842dc1 --- /dev/null +++ b/elixir/apps/domain/test/domain/policies_test.exs @@ -0,0 +1,374 @@ +defmodule Domain.PoliciesTest do + alias Web.Policies + use Domain.DataCase, async: true + import Domain.Policies + + alias Domain.{ + AccountsFixtures, + ActorsFixtures, + AuthFixtures, + PoliciesFixtures, + ResourcesFixtures + } + + alias Domain.Policies + + setup do + account = AccountsFixtures.create_account() + actor = ActorsFixtures.create_actor(type: :account_admin_user, account: account) + identity = AuthFixtures.create_identity(account: account, actor: actor) + subject = AuthFixtures.create_subject(identity) + + %{ + account: account, + actor: actor, + identity: identity, + subject: subject + } + end + + describe "fetch_policy_by_id/2" do + test "returns error when policy does not exist", %{subject: subject} do + assert fetch_policy_by_id(Ecto.UUID.generate(), subject) == {:error, :not_found} + end + + test "returns error when UUID is invalid", %{subject: subject} do + assert fetch_policy_by_id("foo", subject) == {:error, :not_found} + end + + test "returns policy when policy exists", %{account: account, subject: subject} do + policy = PoliciesFixtures.create_policy(account: account) + + assert {:ok, fetched_policy} = fetch_policy_by_id(policy.id, subject) + assert fetched_policy.id == policy.id + end + + test "does not return deleted policy", %{account: account, subject: subject} do + {:ok, policy} = + PoliciesFixtures.create_policy(account: account) + |> delete_policy(subject) + + assert fetch_policy_by_id(policy.id, subject) == {:error, :not_found} + end + + test "does not return policies in other accounts", %{subject: subject} do + policy = PoliciesFixtures.create_policy() + assert fetch_policy_by_id(policy.id, subject) == {:error, :not_found} + end + + test "returns error when subject has no permission to view policies", %{subject: subject} do + subject = AuthFixtures.remove_permissions(subject) + + assert fetch_policy_by_id(Ecto.UUID.generate(), subject) == + {:error, + {:unauthorized, + [ + missing_permissions: [ + {:one_of, + [ + Policies.Authorizer.manage_policies_permission(), + Policies.Authorizer.view_available_policies_permission() + ]} + ] + ]}} + end + + # TODO: add a test that soft-deleted assocs are not preloaded + test "associations are preloaded when opts given", %{account: account, subject: subject} do + policy = PoliciesFixtures.create_policy(account: account) + {:ok, policy} = fetch_policy_by_id(policy.id, subject, preload: [:actor_group, :resource]) + + assert Ecto.assoc_loaded?(policy.actor_group) + assert Ecto.assoc_loaded?(policy.resource) + end + end + + describe "list_policies/1" do + test "returns empty list when there are no policies", %{subject: subject} do + assert list_policies(subject) == {:ok, []} + end + + test "does not list policies from other accounts", %{subject: subject} do + PoliciesFixtures.create_policy() + assert list_policies(subject) == {:ok, []} + end + + test "does not list deleted policies", %{account: account, subject: subject} do + PoliciesFixtures.create_policy(account: account) + |> delete_policy(subject) + + assert list_policies(subject) == {:ok, []} + end + + test "returns all policies for account admin subject", %{account: account} do + actor = ActorsFixtures.create_actor(type: :account_admin_user, account: account) + identity = AuthFixtures.create_identity(account: account, actor: actor) + subject = AuthFixtures.create_subject(identity) + + PoliciesFixtures.create_policy(account: account) + PoliciesFixtures.create_policy(account: account) + PoliciesFixtures.create_policy() + + assert {:ok, policies} = list_policies(subject) + assert length(policies) == 2 + end + + test "returns select policies for non-admin subject", %{account: account, subject: subject} do + unprivileged_actor = ActorsFixtures.create_actor(type: :account_user, account: account) + + unpriviledged_identity = + AuthFixtures.create_identity(account: account, actor: unprivileged_actor) + + unprivileged_subject = AuthFixtures.create_subject(unpriviledged_identity) + + actor_group = ActorsFixtures.create_group(account: account, subject: subject) + + Domain.Actors.update_group( + actor_group, + %{memberships: [%{actor_id: unprivileged_actor.id}]}, + subject + ) + + PoliciesFixtures.create_policy(account: account, actor_group: actor_group) + PoliciesFixtures.create_policy(account: account) + PoliciesFixtures.create_policy() + + assert {:ok, policies} = list_policies(unprivileged_subject) + assert length(policies) == 1 + end + + test "returns error when subject has no permission to view policies", %{subject: subject} do + subject = AuthFixtures.remove_permissions(subject) + + assert list_policies(subject) == + {:error, + {:unauthorized, + [ + missing_permissions: [ + {:one_of, + [ + Policies.Authorizer.manage_policies_permission(), + Policies.Authorizer.view_available_policies_permission() + ]} + ] + ]}} + end + end + + describe "create_policy/2" do + test "returns changeset error on empty params", %{subject: subject} do + assert {:error, changeset} = create_policy(%{}, subject) + + assert errors_on(changeset) == %{ + name: ["can't be blank"], + actor_group_id: ["can't be blank"], + resource_id: ["can't be blank"] + } + end + + test "returns changeset error on invalid params", %{subject: subject} do + assert {:error, changeset} = + create_policy( + %{name: 1, actor_group_id: "foo", resource_id: "bar"}, + subject + ) + + assert errors_on(changeset) == %{name: ["is invalid"]} + end + + test "returns error when subject has no permission to manage policies", %{subject: subject} do + subject = AuthFixtures.remove_permissions(subject) + + assert create_policy(%{}, subject) == + {:error, + {:unauthorized, + [ + missing_permissions: [ + {:one_of, [Policies.Authorizer.manage_policies_permission()]} + ] + ]}} + end + + test "returns changeset error when trying to create policy with another account actor_group", + %{ + account: account, + subject: subject + } do + other_account = AccountsFixtures.create_account() + + resource = ResourcesFixtures.create_resource(account: account) + other_actor_group = ActorsFixtures.create_group(account: other_account) + + attrs = %{ + account_id: account.id, + name: "yikes", + actor_group_id: other_actor_group.id, + resource_id: resource.id + } + + assert {:error, changeset} = create_policy(attrs, subject) + + assert errors_on(changeset) == %{actor_group: ["does not exist"]} + end + + test "returns changeset error when trying to create policy with another account resource", %{ + account: account, + subject: subject + } do + other_account = AccountsFixtures.create_account() + + other_resource = ResourcesFixtures.create_resource(account: other_account) + actor_group = ActorsFixtures.create_group(account: account) + + attrs = %{ + account_id: account.id, + name: "yikes", + actor_group_id: actor_group.id, + resource_id: other_resource.id + } + + assert {:error, changeset} = + create_policy(attrs, subject) + + assert errors_on(changeset) == %{resource: ["does not exist"]} + end + end + + describe "update_policy/3" do + setup context do + policy = + PoliciesFixtures.create_policy( + account: context.account, + subject: context.subject + ) + + Map.put(context, :policy, policy) + end + + test "does nothing on empty params", %{policy: policy, subject: subject} do + assert {:ok, _policy} = update_policy(policy, %{}, subject) + end + + test "returns changeset error on invalid params", %{account: account, subject: subject} do + policy = PoliciesFixtures.create_policy(account: account, subject: subject) + + assert {:error, changeset} = + update_policy( + policy, + %{name: 1, actor_group_id: "foo", resource_id: "bar"}, + subject + ) + + assert errors_on(changeset) == %{name: ["is invalid"]} + end + + test "allows update to name", %{policy: policy, subject: subject} do + assert {:ok, updated_policy} = + update_policy(policy, %{name: "updated policy name"}, subject) + + assert updated_policy.name == "updated policy name" + end + + test "does not allow update to actor_group_id", %{ + policy: policy, + account: account, + subject: subject + } do + new_actor_group = ActorsFixtures.create_group(account: account) + + assert {:ok, updated_policy} = + update_policy(policy, %{actor_group_id: new_actor_group.id}, subject) + + assert updated_policy.actor_group_id == policy.actor_group_id + end + + test "does not allow update to resource_id", %{ + policy: policy, + account: account, + subject: subject + } do + new_resource = ResourcesFixtures.create_resource(account: account) + + assert {:ok, updated_policy} = + update_policy(policy, %{resource_id: new_resource.id}, subject) + + assert updated_policy.resource_id == policy.resource_id + end + + test "returns error when subject has no permission to update policies", %{ + policy: policy, + subject: subject + } do + subject = AuthFixtures.remove_permissions(subject) + + assert update_policy(policy, %{name: "Name Change Attempt"}, subject) == + {:error, + {:unauthorized, + [ + missing_permissions: [ + {:one_of, [Policies.Authorizer.manage_policies_permission()]} + ] + ]}} + end + + test "return error when subject is outside of account", %{policy: policy} do + other_account = AccountsFixtures.create_account() + other_actor = ActorsFixtures.create_actor(type: :account_admin_user, account: other_account) + other_identity = AuthFixtures.create_identity(account: other_account, actor: other_actor) + other_subject = AuthFixtures.create_subject(other_identity) + + assert {:error, :unauthorized} = + update_policy(policy, %{name: "Should not be allowed"}, other_subject) + end + end + + describe "delete_policy/2" do + setup context do + policy = + PoliciesFixtures.create_policy( + account: context.account, + subject: context.subject + ) + + Map.put(context, :policy, policy) + end + + test "deletes policy", %{policy: policy, subject: subject} do + assert {:ok, deleted_policy} = delete_policy(policy, subject) + assert deleted_policy.deleted_at != nil + end + + test "returns error when subject has no permission to delete policies", %{ + policy: policy, + subject: subject + } do + subject = AuthFixtures.remove_permissions(subject) + + assert delete_policy(policy, subject) == + {:error, + {:unauthorized, + [ + missing_permissions: [ + {:one_of, [Policies.Authorizer.manage_policies_permission()]} + ] + ]}} + end + + test "returns error on state conflict", %{policy: policy, subject: subject} do + assert {:ok, deleted_policy} = delete_policy(policy, subject) + assert delete_policy(deleted_policy, subject) == {:error, :not_found} + assert delete_policy(policy, subject) == {:error, :not_found} + end + + test "returns error when subject attempts to delete policy outside of account", %{ + policy: policy + } do + other_account = AccountsFixtures.create_account() + other_actor = ActorsFixtures.create_actor(type: :account_admin_user, account: other_account) + other_identity = AuthFixtures.create_identity(account: other_account, actor: other_actor) + other_subject = AuthFixtures.create_subject(other_identity) + + assert delete_policy(policy, other_subject) == {:error, :not_found} + end + end +end diff --git a/elixir/apps/domain/test/support/fixtures/policies_fixtures.ex b/elixir/apps/domain/test/support/fixtures/policies_fixtures.ex new file mode 100644 index 000000000..e64687e5a --- /dev/null +++ b/elixir/apps/domain/test/support/fixtures/policies_fixtures.ex @@ -0,0 +1,61 @@ +defmodule Domain.PoliciesFixtures do + alias Domain.{AccountsFixtures, ActorsFixtures, AuthFixtures, ResourcesFixtures} + + def policy_attrs(attrs \\ %{}) do + name = "policy-#{counter()}" + + Enum.into(attrs, %{ + name: name, + actor_group_id: nil, + resource_id: nil + }) + end + + def create_policy(attrs \\ %{}) do + attrs = policy_attrs(attrs) + + {account, attrs} = + Map.pop_lazy(attrs, :account, fn -> + AccountsFixtures.create_account() + end) + + {subject, attrs} = + Map.pop_lazy(attrs, :subject, fn -> + actor = ActorsFixtures.create_actor(type: :account_admin_user, account: account) + identity = AuthFixtures.create_identity(account: account, actor: actor) + AuthFixtures.create_subject(identity) + end) + + {actor_group, attrs} = + Map.pop_lazy(attrs, :actor_group, fn -> + ActorsFixtures.create_group(account: account, subject: subject) + end) + + {actor_group_id, attrs} = + Map.pop_lazy(attrs, :actor_group, fn -> + actor_group.id + end) + + {resource, attrs} = + Map.pop_lazy(attrs, :resource, fn -> + ResourcesFixtures.create_resource(account: account, subject: subject) + end) + + {resource_id, attrs} = + Map.pop_lazy(attrs, :resource, fn -> + resource.id + end) + + {:ok, policy} = + attrs + |> Map.put(:actor_group_id, actor_group_id) + |> Map.put(:resource_id, resource_id) + |> Domain.Policies.create_policy(subject) + + policy + end + + defp counter do + System.unique_integer([:positive]) + end +end diff --git a/elixir/apps/web/lib/web/live/policies/edit.ex b/elixir/apps/web/lib/web/live/policies/edit.ex index dd14dee24..5253a1aab 100644 --- a/elixir/apps/web/lib/web/live/policies/edit.ex +++ b/elixir/apps/web/lib/web/live/policies/edit.ex @@ -1,20 +1,30 @@ defmodule Web.Policies.Edit do use Web, :live_view + alias Domain.Policies + + def mount(%{"id" => id} = _params, _session, socket) do + with {:ok, policy} <- Policies.fetch_policy_by_id(id, socket.assigns.subject) do + {:ok, assign(socket, policy: policy)} + else + _other -> raise Web.LiveErrors.NotFoundError + end + end + def render(assigns) do ~H""" <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> <.breadcrumb path={~p"/#{@account}/policies"}>Policies - <.breadcrumb path={~p"/#{@account}/policies/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}> - Engineering access to GitLab + <.breadcrumb path={~p"/#{@account}/policies/#{@policy}"}> + <%= @policy.name %> - <.breadcrumb path={~p"/#{@account}/policies/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89/edit"}> + <.breadcrumb path={~p"/#{@account}/policies/#{@policy}/edit"}> Edit <.header> <:title> - Edit Policy Engineering access to GitLab + Edit Policy <%= @policy.name %> @@ -27,24 +37,21 @@ defmodule Web.Policies.Edit do <.label for="name"> Name -
- +
diff --git a/elixir/apps/web/lib/web/live/policies/index.ex b/elixir/apps/web/lib/web/live/policies/index.ex index e7e648dda..c68240370 100644 --- a/elixir/apps/web/lib/web/live/policies/index.ex +++ b/elixir/apps/web/lib/web/live/policies/index.ex @@ -1,5 +1,15 @@ defmodule Web.Policies.Index do use Web, :live_view + alias Domain.Policies + + def mount(_params, _session, socket) do + with {:ok, policies} <- + Policies.list_policies(socket.assigns.subject, preload: [:actor_group, :resource]) do + {:ok, assign(socket, policies: policies)} + else + _other -> raise Web.LiveErrors.NotFoundError + end + end def render(assigns) do ~H""" @@ -18,171 +28,64 @@ defmodule Web.Policies.Index do
-
-
-
- -
-
- <.icon name="hero-magnifying-glass" class="w-5 h-5 text-gray-500 dark:text-gray-400" /> -
- -
-
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - - - - Actions -
- <.link - navigate={~p"/#{@account}/policies/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} - class="font-medium text-blue-600 dark:text-blue-500 hover:underline" - > - Engineering access to Gitlab - - - <.link - class="inline-block" - navigate={~p"/#{@account}/groups/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} - > - - Engineering - - - - <.link - class="text-blue-600 dark:text-blue-500 hover:underline" - navigate={~p"/#{@account}/resources/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} - > - GitLab - - - <.link navigate="#" class="text-blue-600 dark:text-blue-500 hover:underline"> - Delete - -
- <.link - navigate={~p"/#{@account}/policies/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} - class="font-medium text-blue-600 dark:text-blue-500 hover:underline" - > - IT access to Staging VPC - - - <.link - class="inline-block" - navigate={~p"/#{@account}/groups/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} - > - - IT - - - - <.link - class="text-blue-600 dark:text-blue-500 hover:underline" - navigate={~p"/#{@account}/resources/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} - > - Staging VPC - - - <.link navigate="#" class="text-blue-600 dark:text-blue-500 hover:underline"> - Delete - -
- <.link - navigate={~p"/#{@account}/policies/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} - class="font-medium text-blue-600 dark:text-blue-500 hover:underline" - > - Admin access to Jira - - - <.link - class="inline-block" - navigate={~p"/#{@account}/groups/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} - > - - Admin - - - - <.link - class="text-blue-600 dark:text-blue-500 hover:underline" - navigate={~p"/#{@account}/resources/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} - > - Jira - - - <.link navigate="#" class="text-blue-600 dark:text-blue-500 hover:underline"> - Delete - -
-
+ <.resource_filter /> + <.table id="policies" rows={@policies} row_id={&"policies-#{&1.id}"}> + <:col :let={policy} label="NAME"> + <.link + navigate={~p"/#{@account}/policies/#{policy}"} + class="font-medium text-blue-600 dark:text-blue-500 hover:underline" + > + <%= policy.name %> + + + <:col :let={policy} label="GROUP"> + <.badge> + <%= policy.actor_group.name %> + + + <:col :let={policy} label="RESOURCE"> + <.link + navigate={~p"/#{@account}/resources/#{policy.resource_id}"} + class="font-medium text-blue-600 dark:text-blue-500 hover:underline" + > + <%= policy.resource.name %> + + + <:action> + + Delete + + + <.paginator page={3} total_pages={100} collection_base_path={~p"/#{@account}/gateways"} />
""" end + + def resource_filter(assigns) do + ~H""" +
+
+
+ +
+
+ <.icon name="hero-magnifying-glass" class="w-5 h-5 text-gray-500 dark:text-gray-400" /> +
+ +
+
+
+
+ """ + end end diff --git a/elixir/apps/web/lib/web/live/policies/new.ex b/elixir/apps/web/lib/web/live/policies/new.ex index 53bf0f22a..0e60ea93a 100644 --- a/elixir/apps/web/lib/web/live/policies/new.ex +++ b/elixir/apps/web/lib/web/live/policies/new.ex @@ -1,6 +1,23 @@ defmodule Web.Policies.New do use Web, :live_view + alias Domain.{Resources, Actors} + + def mount(_params, _session, socket) do + with {:ok, resources} <- Resources.list_resources(socket.assigns.subject), + {:ok, actor_groups} <- Actors.list_groups(socket.assigns.subject) do + socket = + assign(socket, + resources: resources, + actor_groups: actor_groups + ) + + {:ok, socket} + else + _other -> raise Web.LiveErrors.NotFoundError + end + end + def render(assigns) do ~H""" <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> @@ -18,59 +35,47 @@ defmodule Web.Policies.New do

Policy details

+
+ <.label for="policy-name"> + Name + + <.input + autocomplete="off" + type="text" + name="name" + value="" + id="policy-name" + placeholder="Enter a name for this policy" + /> +
<.label for="group"> Group - + <.input + type="select" + options={Enum.map(@actor_groups, fn g -> [key: g.name, value: g.id] end)} + name="actor_group" + value="" + />
<.label for="resource"> Resource - -
-
- <.label for="policy-name"> - Name - - [key: r.name, value: r.id] end)} + name="resource" + value="" />
- +
diff --git a/elixir/apps/web/lib/web/live/policies/show.ex b/elixir/apps/web/lib/web/live/policies/show.ex index 3613959b1..d21cac0d8 100644 --- a/elixir/apps/web/lib/web/live/policies/show.ex +++ b/elixir/apps/web/lib/web/live/policies/show.ex @@ -1,80 +1,92 @@ defmodule Web.Policies.Show do use Web, :live_view + alias Domain.Policies + + def mount(%{"id" => id} = _params, _session, socket) do + with {:ok, policy} <- + Policies.fetch_policy_by_id(id, socket.assigns.subject, + preload: [:actor_group, :resource, [created_by_identity: :actor]] + ) do + {:ok, assign(socket, policy: policy)} + else + _other -> raise Web.LiveErrors.NotFoundError + end + end + + defp pretty_print_date(date) do + "#{date.month}/#{date.day}/#{date.year} #{date.hour}:#{date.minute}:#{date.second}" + end def render(assigns) do ~H""" <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> <.breadcrumb path={~p"/#{@account}/policies"}>Policies - <.breadcrumb path={~p"/#{@account}/policies/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}> - Engineering access to GitLab + <.breadcrumb path={~p"/#{@account}/policies/#{@policy}"}> + <%= @policy.name %> <.header> <:title> - Viewing Policy Engineering access to GitLab + Viewing Policy <%= @policy.name %> <:actions> - <.edit_button navigate={~p"/#{@account}/policies/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89/edit"}> + <.edit_button navigate={~p"/#{@account}/policies/#{@policy}/edit"}> Edit Policy
- - - - - - - - - - - - - - - - - - - -
+ <.vertical_table_row> + <:label> + Name + + <:value> + <%= @policy.name %> + + + <.vertical_table_row> + <:label> + Group + + <:value> + <.link + navigate={~p"/#{@account}/groups/#{@policy.actor_group_id}"} + class="text-blue-600 dark:text-blue-500 hover:underline" > - Name - - Engineering access to GitLab -
+ + + + <.vertical_table_row> + <:label> + Resource + + <:value> + <.link + navigate={~p"/#{@account}/resources/#{@policy.resource_id}"} + class="text-blue-600 dark:text-blue-500 hover:underline" > - Group - - Engineering -
+ + + + <.vertical_table_row> + <:label> + Created + + <:value> + <%= pretty_print_date(@policy.inserted_at) %> by + <.link + navigate={~p"/#{@account}/actors/#{@policy.created_by_identity.actor.id}"} + class="text-blue-600 dark:text-blue-500 hover:underline" > - Resource - - GitLab -
- Created - - 4/15/22 12:32 PM by - <.link - class="text-blue-600 hover:underline" - navigate={~p"/#{@account}/actors/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} - > - Andrew Dryga - -
+ <%= @policy.created_by_identity.actor.name %> + + + +