refactor(portal): API persistent IDs (#7182)

In order for the firezone terraform provider to work properly, the
Resources and Policies need to be able to be referenced by their
`persistent_id`, specifically in the portal API.
This commit is contained in:
Brian Manifold
2024-11-07 15:45:56 -05:00
committed by GitHub
parent 83dfd3a98c
commit 06791d2d05
23 changed files with 330 additions and 47 deletions

View File

@@ -43,7 +43,7 @@ defmodule API.PolicyController do
# Show a specific Policy
def show(conn, %{"id" => id}) do
with {:ok, policy} <- Policies.fetch_policy_by_id(id, conn.assigns.subject) do
with {:ok, policy} <- Policies.fetch_policy_by_id_or_persistent_id(id, conn.assigns.subject) do
render(conn, :show, policy: policy)
end
end
@@ -91,7 +91,7 @@ defmodule API.PolicyController do
def update(conn, %{"id" => id, "policy" => params}) do
subject = conn.assigns.subject
with {:ok, policy} <- Policies.fetch_policy_by_id(id, subject) do
with {:ok, policy} <- Policies.fetch_policy_by_id_or_persistent_id(id, subject) do
case Policies.update_or_replace_policy(policy, params, subject) do
{:updated, updated_policy} ->
render(conn, :show, policy: updated_policy)
@@ -127,7 +127,7 @@ defmodule API.PolicyController do
def delete(conn, %{"id" => id}) do
subject = conn.assigns.subject
with {:ok, policy} <- Policies.fetch_policy_by_id(id, subject),
with {:ok, policy} <- Policies.fetch_policy_by_id_or_persistent_id(id, subject),
{:ok, policy} <- Policies.delete_policy(policy, subject) do
render(conn, :show, policy: policy)
end

View File

@@ -42,7 +42,8 @@ defmodule API.ResourceController do
]
def show(conn, %{"id" => id}) do
with {:ok, resource} <- Resources.fetch_resource_by_id(id, conn.assigns.subject) do
with {:ok, resource} <-
Resources.fetch_resource_by_id_or_persistent_id(id, conn.assigns.subject) do
render(conn, :show, resource: resource)
end
end
@@ -91,7 +92,7 @@ defmodule API.ResourceController do
subject = conn.assigns.subject
attrs = set_param_defaults(params)
with {:ok, resource} <- Resources.fetch_resource_by_id(id, subject) do
with {:ok, resource} <- Resources.fetch_resource_by_id_or_persistent_id(id, subject) do
case Resources.update_or_replace_resource(resource, attrs, subject) do
{:updated, updated_resource} ->
render(conn, :show, resource: updated_resource)
@@ -126,7 +127,7 @@ defmodule API.ResourceController do
def delete(conn, %{"id" => id}) do
subject = conn.assigns.subject
with {:ok, resource} <- Resources.fetch_resource_by_id(id, subject),
with {:ok, resource} <- Resources.fetch_resource_by_id_or_persistent_id(id, subject),
{:ok, resource} <- Resources.delete_resource(resource, subject) do
render(conn, :show, resource: resource)
end

View File

@@ -24,7 +24,7 @@ defmodule API.ResourceJSON do
id: resource.id,
name: resource.name,
address: resource.address,
description: resource.address_description,
address_description: resource.address_description,
type: resource.type
}
end

View File

@@ -31,7 +31,7 @@ defmodule API.Schemas.ActorGroup do
description: "POST body for creating an Actor Group",
type: :object,
properties: %{
actor_group: %Schema{anyOf: [ActorGroup.Schema]}
actor_group: ActorGroup.Schema
},
required: [:actor_group],
example: %{

View File

@@ -37,7 +37,7 @@ defmodule API.Schemas.Actor do
description: "POST body for creating an Actor",
type: :object,
properties: %{
actor: %Schema{anyOf: [Actor.Schema]}
actor: Actor.Schema
},
required: [:actor],
example: %{

View File

@@ -31,7 +31,7 @@ defmodule API.Schemas.GatewayGroup do
description: "POST body for creating a Gateway Group",
type: :object,
properties: %{
gateway_group: %Schema{anyOf: [GatewayGroup.Schema]}
gateway_group: GatewayGroup.Schema
},
required: [:gateway_group],
example: %{

View File

@@ -35,7 +35,7 @@ defmodule API.Schemas.Identity do
description: "POST body for creating a Identity",
type: :object,
properties: %{
identity: %Schema{anyOf: [Identity.Schema]}
identity: Identity.Schema
},
required: [:identity],
example: %{

View File

@@ -35,7 +35,7 @@ defmodule API.Schemas.Policy do
description: "POST body for creating a Policy",
type: :object,
properties: %{
policy: %Schema{anyOf: [Policy.Schema]}
policy: Policy.Schema
},
required: [:policy],
example: %{

View File

@@ -13,7 +13,7 @@ defmodule API.Schemas.Resource do
id: %Schema{type: :string, description: "Resource ID"},
name: %Schema{type: :string, description: "Resource name"},
address: %Schema{type: :string, description: "Resource address"},
description: %Schema{type: :string, description: "Resource description"},
address_description: %Schema{type: :string, description: "Resource address description"},
type: %Schema{type: :string, description: "Resource type"}
},
required: [:name, :type],
@@ -21,7 +21,7 @@ defmodule API.Schemas.Resource do
"id" => "42a7f82f-831a-4a9d-8f17-c66c2bb6e205",
"name" => "Prod DB",
"address" => "10.0.0.10",
"description" => "Production Database",
"address_description" => "Production Database",
"type" => "ip"
}
})
@@ -32,29 +32,27 @@ defmodule API.Schemas.Resource do
alias OpenApiSpex.Schema
alias API.Schemas.Resource
properties =
Map.merge(Resource.Schema.schema().properties, %{
connections: %Schema{
title: "Connections",
description: "Gateway Groups to connect the Resource to",
type: :array,
items: %Schema{
type: :object,
properties: %{
gateway_group_id: %Schema{type: :string, description: "Gateway Group ID"}
}
}
}
})
OpenApiSpex.schema(%{
title: "ResourceRequest",
description: "POST body for creating a Resource",
type: :object,
properties: %{
resource: %Schema{
anyOf: [
Resource.Schema
],
properties: %{
connections: %Schema{
title: "Connections",
description: "Gateway Groups to connect the Resource to",
type: :array,
items: %Schema{
type: :object,
properties: %{
gateway_group_id: %Schema{type: :string, description: "Gateway Group ID"}
}
}
}
}
}
resource: %Schema{properties: properties}
},
required: [:resource],
example: %{
@@ -62,7 +60,7 @@ defmodule API.Schemas.Resource do
"id" => "42a7f82f-831a-4a9d-8f17-c66c2bb6e205",
"name" => "Prod DB",
"address" => "10.0.0.10",
"description" => "Production Database",
"address_description" => "Production Database",
"type" => "ip",
"connections" => [
%{
@@ -91,7 +89,7 @@ defmodule API.Schemas.Resource do
"id" => "42a7f82f-831a-4a9d-8f17-c66c2bb6e205",
"name" => "Prod DB",
"address" => "10.0.0.10",
"description" => "Production Database",
"address_description" => "Production Database",
"type" => "ip"
}
}
@@ -117,14 +115,14 @@ defmodule API.Schemas.Resource do
"id" => "42a7f82f-831a-4a9d-8f17-c66c2bb6e205",
"name" => "Prod DB",
"address" => "10.0.0.10",
"description" => "Production Database",
"address_description" => "Production Database",
"type" => "ip"
},
%{
"id" => "3b9451c9-5616-48f8-827f-009ace22d015",
"name" => "Admin Dashboard",
"address" => "10.0.0.20",
"description" => "Production Admin Dashboard",
"address_description" => "Production Admin Dashboard",
"type" => "ip"
}
],

View File

@@ -59,7 +59,7 @@ defmodule API.MixProject do
# Other deps
{:jason, "~> 1.2"},
{:remote_ip, "~> 1.1"},
{:open_api_spex, "~> 3.18"},
{:open_api_spex, "~> 3.21.2"},
{:ymlr, "~> 5.0"},
# Test deps

View File

@@ -98,7 +98,7 @@ defmodule API.ResourceControllerTest do
assert json_response(conn, 200) == %{
"data" => %{
"address" => resource.address,
"description" => resource.address_description,
"address_description" => resource.address_description,
"id" => resource.id,
"name" => resource.name,
"type" => Atom.to_string(resource.type)
@@ -169,7 +169,7 @@ defmodule API.ResourceControllerTest do
assert resp = json_response(conn, 201)
assert resp["data"]["address"] == attrs["address"]
assert resp["data"]["description"] == nil
assert resp["data"]["address_description"] == nil
assert resp["data"]["name"] == attrs["name"]
assert resp["data"]["type"] == attrs["type"]
end
@@ -209,7 +209,7 @@ defmodule API.ResourceControllerTest do
assert resp = json_response(conn, 200)
assert resp["data"]["address"] == resource.address
assert resp["data"]["description"] == resource.address_description
assert resp["data"]["address_description"] == resource.address_description
assert resp["data"]["name"] == attrs["name"]
end
end
@@ -233,7 +233,7 @@ defmodule API.ResourceControllerTest do
assert json_response(conn, 200) == %{
"data" => %{
"address" => resource.address,
"description" => resource.address_description,
"address_description" => resource.address_description,
"id" => resource.id,
"name" => resource.name,
"type" => Atom.to_string(resource.type)

View File

@@ -23,6 +23,26 @@ defmodule Domain.Policies do
end
end
def fetch_policy_by_id_or_persistent_id(id, %Auth.Subject{} = subject, opts \\ []) do
required_permissions =
{:one_of,
[
Authorizer.manage_policies_permission(),
Authorizer.view_available_policies_permission()
]}
with :ok <- Auth.ensure_has_permissions(subject, required_permissions),
true <- Repo.valid_uuid?(id) do
Policy.Query.all()
|> Policy.Query.by_id_or_persistent_id(id)
|> Authorizer.for_subject(subject)
|> Repo.fetch(Policy.Query, opts)
else
false -> {:error, :not_found}
other -> other
end
end
def list_policies(%Auth.Subject{} = subject, opts \\ []) do
required_permissions =
{:one_of,

View File

@@ -22,6 +22,14 @@ defmodule Domain.Policies.Policy.Query do
where(queryable, [policies: policies], policies.id == ^id)
end
def by_id_or_persistent_id(queryable, id) do
where(queryable, [policies: policies], policies.id == ^id)
|> or_where(
[policies: policies],
policies.persistent_id == ^id and is_nil(policies.replaced_by_policy_id)
)
end
def by_account_id(queryable, account_id) do
where(queryable, [policies: policies], policies.account_id == ^account_id)
end

View File

@@ -23,6 +23,26 @@ defmodule Domain.Resources do
end
end
def fetch_resource_by_id_or_persistent_id(id, %Auth.Subject{} = subject, opts \\ []) do
required_permissions =
{:one_of,
[
Authorizer.manage_resources_permission(),
Authorizer.view_available_resources_permission()
]}
with :ok <- Auth.ensure_has_permissions(subject, required_permissions),
true <- Repo.valid_uuid?(id) do
Resource.Query.all()
|> Resource.Query.by_id_or_persistent_id(id)
|> Authorizer.for_subject(Resource, subject)
|> Repo.fetch(Resource.Query, opts)
else
false -> {:error, :not_found}
other -> other
end
end
def fetch_and_authorize_resource_by_id(id, %Auth.Subject{} = subject, opts \\ []) do
with :ok <-
Auth.ensure_has_permissions(subject, Authorizer.view_available_resources_permission()),
@@ -48,6 +68,16 @@ defmodule Domain.Resources do
end
end
def fetch_resource_by_id_or_persistent_id!(id) do
if Repo.valid_uuid?(id) do
Resource.Query.not_deleted()
|> Resource.Query.by_id_or_persistent_id(id)
|> Repo.one!()
else
{:error, :not_found}
end
end
def all_authorized_resources(%Auth.Subject{} = subject, opts \\ []) do
with :ok <-
Auth.ensure_has_permissions(subject, Authorizer.view_available_resources_permission()) do

View File

@@ -26,6 +26,14 @@ defmodule Domain.Resources.Resource.Query do
where(queryable, [resources: resources], resources.id == ^id)
end
def by_id_or_persistent_id(queryable, id) do
where(queryable, [resources: resources], resources.id == ^id)
|> or_where(
[resources: resources],
resources.persistent_id == ^id and is_nil(resources.replaced_by_resource_id)
)
end
def by_account_id(queryable, account_id) do
where(queryable, [resources: resources], resources.account_id == ^account_id)
end

View File

@@ -0,0 +1,17 @@
defmodule Domain.Repo.Migrations.AddPersistentIdIndexes do
use Ecto.Migration
def change do
execute("""
CREATE INDEX resource_persistent_id_index
ON resources (persistent_id)
WHERE replaced_by_resource_id IS NULL
""")
execute("""
CREATE INDEX policy_persistent_id_index
ON policies (persistent_id)
WHERE replaced_by_policy_id IS NULL
""")
end
end

View File

@@ -73,6 +73,88 @@ defmodule Domain.PoliciesTest do
end
end
describe "fetch_policy_by_id_or_persistent_id/3" do
test "returns error when policy does not exist", %{subject: subject} do
assert fetch_policy_by_id_or_persistent_id(Ecto.UUID.generate(), subject) ==
{:error, :not_found}
end
test "returns error when UUID is invalid", %{subject: subject} do
assert fetch_policy_by_id_or_persistent_id("foo", subject) == {:error, :not_found}
end
test "returns policy when policy exists", %{account: account, subject: subject} do
policy = Fixtures.Policies.create_policy(account: account)
assert {:ok, fetched_policy} = fetch_policy_by_id_or_persistent_id(policy.id, subject)
assert fetched_policy.id == policy.id
assert {:ok, fetched_policy} =
fetch_policy_by_id_or_persistent_id(policy.persistent_id, subject)
assert fetched_policy.id == policy.id
end
test "returns deleted policies", %{account: account, subject: subject} do
{:ok, policy} =
Fixtures.Policies.create_policy(account: account)
|> delete_policy(subject)
assert {:ok, fetched_policy} = fetch_policy_by_id_or_persistent_id(policy.id, subject)
assert fetched_policy.id == policy.id
assert {:ok, fetched_policy} =
fetch_policy_by_id_or_persistent_id(policy.persistent_id, subject)
assert fetched_policy.id == policy.id
end
test "does not return policies in other accounts", %{subject: subject} do
policy = Fixtures.Policies.create_policy()
assert fetch_policy_by_id_or_persistent_id(policy.id, subject) == {:error, :not_found}
assert fetch_policy_by_id_or_persistent_id(policy.persistent_id, subject) ==
{:error, :not_found}
end
test "returns error when subject has no permission to view policies", %{subject: subject} do
subject = Fixtures.Auth.remove_permissions(subject)
assert fetch_policy_by_id_or_persistent_id(Ecto.UUID.generate(), subject) ==
{:error,
{:unauthorized,
reason: :missing_permissions,
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 = Fixtures.Policies.create_policy(account: account)
{:ok, policy} =
fetch_policy_by_id_or_persistent_id(policy.id, subject,
preload: [:actor_group, :resource]
)
assert Ecto.assoc_loaded?(policy.actor_group)
assert Ecto.assoc_loaded?(policy.resource)
{:ok, policy} =
fetch_policy_by_id_or_persistent_id(policy.persistent_id, subject,
preload: [:actor_group, :resource]
)
assert Ecto.assoc_loaded?(policy.actor_group)
assert Ecto.assoc_loaded?(policy.resource)
end
end
describe "list_policies/2" do
test "returns empty list when there are no policies", %{subject: subject} do
assert {:ok, [], _metadata} = list_policies(subject)

View File

@@ -104,6 +104,123 @@ defmodule Domain.ResourcesTest do
end
end
describe "fetch_resource_by_id_or_persistent_id/3" do
test "returns error when resource does not exist", %{subject: subject} do
assert fetch_resource_by_id_or_persistent_id(Ecto.UUID.generate(), subject) ==
{:error, :not_found}
end
test "returns error when UUID is invalid", %{subject: subject} do
assert fetch_resource_by_id_or_persistent_id("foo", subject) == {:error, :not_found}
end
test "returns resource for account admin", %{account: account, subject: subject} do
resource = Fixtures.Resources.create_resource(account: account)
assert {:ok, fetched_resource} = fetch_resource_by_id_or_persistent_id(resource.id, subject)
assert fetched_resource.id == resource.id
assert {:ok, fetched_resource} =
fetch_resource_by_id_or_persistent_id(resource.persistent_id, subject)
assert fetched_resource.id == resource.id
end
test "returns authorized resource for account user", %{
account: account
} do
actor_group = Fixtures.Actors.create_group(account: account)
actor = Fixtures.Actors.create_actor(type: :account_user, account: account)
Fixtures.Actors.create_membership(account: account, actor: actor, group: actor_group)
identity = Fixtures.Auth.create_identity(account: account, actor: actor)
subject = Fixtures.Auth.create_subject(identity: identity)
resource = Fixtures.Resources.create_resource(account: account)
assert fetch_resource_by_id_or_persistent_id(resource.id, subject) == {:error, :not_found}
assert fetch_resource_by_id_or_persistent_id(resource.persistent_id, subject) ==
{:error, :not_found}
policy =
Fixtures.Policies.create_policy(
account: account,
actor_group: actor_group,
resource: resource
)
assert {:ok, fetched_resource} = fetch_resource_by_id_or_persistent_id(resource.id, subject)
assert fetched_resource.id == resource.id
assert Enum.map(fetched_resource.authorized_by_policies, & &1.id) == [policy.id]
assert {:ok, fetched_resource} =
fetch_resource_by_id_or_persistent_id(resource.persistent_id, subject)
assert fetched_resource.id == resource.id
assert Enum.map(fetched_resource.authorized_by_policies, & &1.id) == [policy.id]
end
test "returns deleted resources", %{account: account, subject: subject} do
{:ok, resource} =
Fixtures.Resources.create_resource(account: account)
|> delete_resource(subject)
assert {:ok, _resource} = fetch_resource_by_id_or_persistent_id(resource.id, subject)
assert {:ok, _resource} =
fetch_resource_by_id_or_persistent_id(resource.persistent_id, subject)
end
test "does not return resources in other accounts", %{subject: subject} do
resource = Fixtures.Resources.create_resource()
assert fetch_resource_by_id_or_persistent_id(resource.id, subject) == {:error, :not_found}
assert fetch_resource_by_id_or_persistent_id(resource.persistent_id, subject) ==
{:error, :not_found}
end
test "returns error when subject has no permission to view resources", %{subject: subject} do
subject = Fixtures.Auth.remove_permissions(subject)
assert fetch_resource_by_id_or_persistent_id(Ecto.UUID.generate(), subject) ==
{:error,
{:unauthorized,
reason: :missing_permissions,
missing_permissions: [
{:one_of,
[
Resources.Authorizer.manage_resources_permission(),
Resources.Authorizer.view_available_resources_permission()
]}
]}}
end
test "associations are preloaded when opts given", %{account: account, subject: subject} do
gateway_group = Fixtures.Gateways.create_group(account: account)
resource =
Fixtures.Resources.create_resource(
account: account,
connections: [%{gateway_group_id: gateway_group.id}]
)
assert {:ok, resource} =
fetch_resource_by_id_or_persistent_id(resource.id, subject, preload: :connections)
assert Ecto.assoc_loaded?(resource.connections)
assert length(resource.connections) == 1
assert {:ok, resource} =
fetch_resource_by_id_or_persistent_id(resource.persistent_id, subject,
preload: :connections
)
assert Ecto.assoc_loaded?(resource.connections)
assert length(resource.connections) == 1
end
end
describe "fetch_and_authorize_resource_by_id/3" do
test "returns error when resource does not exist", %{subject: subject} do
assert fetch_and_authorize_resource_by_id(Ecto.UUID.generate(), subject) ==

View File

@@ -5,7 +5,7 @@ defmodule Web.Policies.Edit do
def mount(%{"id" => id}, _session, socket) do
with {:ok, policy} <-
Policies.fetch_policy_by_id(id, socket.assigns.subject,
Policies.fetch_policy_by_id_or_persistent_id(id, socket.assigns.subject,
preload: [:actor_group, :resource],
filter: [deleted?: false]
) do

View File

@@ -5,7 +5,7 @@ defmodule Web.Policies.Show do
def mount(%{"id" => id}, _session, socket) do
with {:ok, policy} <-
Policies.fetch_policy_by_id(id, socket.assigns.subject,
Policies.fetch_policy_by_id_or_persistent_id(id, socket.assigns.subject,
preload: [
actor_group: [:provider],
resource: [],
@@ -314,7 +314,9 @@ defmodule Web.Policies.Show do
def handle_info({_action, _policy_id}, socket) do
{:ok, policy} =
Policies.fetch_policy_by_id(socket.assigns.policy.id, socket.assigns.subject,
Policies.fetch_policy_by_id_or_persistent_id(
socket.assigns.policy.id,
socket.assigns.subject,
preload: [
actor_group: [:provider],
resource: [],

View File

@@ -10,7 +10,7 @@ defmodule Web.Resources.Components do
}
def fetch_resource_option(id, subject) do
{:ok, resource} = Resources.fetch_resource_by_id(id, subject)
{:ok, resource} = Resources.fetch_resource_by_id_or_persistent_id(id, subject)
{:ok, resource_option(resource)}
end

View File

@@ -6,7 +6,7 @@ defmodule Web.Resources.Show do
def mount(%{"id" => id} = params, _session, socket) do
with {:ok, resource} <-
Resources.fetch_resource_by_id(id, socket.assigns.subject,
Resources.fetch_resource_by_id_or_persistent_id(id, socket.assigns.subject,
preload: [
:gateway_groups,
:created_by_actor,

View File

@@ -61,7 +61,7 @@
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"number": {:hex, :number, "1.0.5", "d92136f9b9382aeb50145782f116112078b3465b7be58df1f85952b8bb399b0f", [:mix], [{:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "c0733a0a90773a66582b9e92a3f01290987f395c972cb7d685f51dd927cd5169"},
"observer_cli": {:hex, :observer_cli, "1.7.5", "cf73407c40ba3933a4be8be5cdbfcd647a7ec24b49f1d75e912ae1f2e58bc5d4", [:mix, :rebar3], [{:recon, "~> 2.5.5", [hex: :recon, repo: "hexpm", optional: false]}], "hexpm", "872cf8e833a3a71ebd05420692678ec8aaede8fd96c805a4687398f6b23a3014"},
"open_api_spex": {:hex, :open_api_spex, "3.21.0", "0582a58d48818260636edffc40a28c378be93f68f0735a62c10ada574dcab5b8", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0 or ~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}, {:ymlr, "~> 2.0 or ~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :ymlr, repo: "hexpm", optional: true]}], "hexpm", "acb595f268b2bd1ac1c9bd70cc2246de43b8fc86a496f8e525272d240b5f1aa4"},
"open_api_spex": {:hex, :open_api_spex, "3.21.2", "6a704f3777761feeb5657340250d6d7332c545755116ca98f33d4b875777e1e5", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0 or ~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}, {:ymlr, "~> 2.0 or ~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :ymlr, repo: "hexpm", optional: true]}], "hexpm", "f42ae6ed668b895ebba3e02773cfb4b41050df26f803f2ef634c72a7687dc387"},
"openid_connect": {:git, "https://github.com/firezone/openid_connect.git", "e4d9dca8ae43c765c00a7d3dfa12d6f24f5b3418", [ref: "e4d9dca8ae43c765c00a7d3dfa12d6f24f5b3418"]},
"opentelemetry": {:hex, :opentelemetry, "1.4.0", "f928923ed80adb5eb7894bac22e9a198478e6a8f04020ae1d6f289fdcad0b498", [:rebar3], [{:opentelemetry_api, "~> 1.3.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:opentelemetry_semantic_conventions, "~> 0.2", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: false]}], "hexpm", "50b32ce127413e5d87b092b4d210a3449ea80cd8224090fe68d73d576a3faa15"},
"opentelemetry_api": {:hex, :opentelemetry_api, "1.3.1", "83b4713593f80562d9643c4ab0b6f80f3c5fa4c6d0632c43e11b2ccb6b04dfa7", [:mix, :rebar3], [{:opentelemetry_semantic_conventions, "~> 0.2", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: false]}], "hexpm", "9e8a5cc38671e3ac61be48abe5f6b3afdbbb50a1dc08b7950c56f169611505c1"},