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
-
-
-
-
-
- |
-
- |
-
-
- |
-
-
- |
-
- 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"""
+
+ """
+ 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
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 %>
+
+
+
+