diff --git a/elixir/apps/api/.formatter.exs b/elixir/apps/api/.formatter.exs index 47616780b..0cc92f966 100644 --- a/elixir/apps/api/.formatter.exs +++ b/elixir/apps/api/.formatter.exs @@ -1,4 +1,4 @@ [ - import_deps: [:phoenix], + import_deps: [:phoenix, :open_api_spex], inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}"] ] diff --git a/elixir/apps/api/lib/api/api_spec.ex b/elixir/apps/api/lib/api/api_spec.ex new file mode 100644 index 000000000..96636106c --- /dev/null +++ b/elixir/apps/api/lib/api/api_spec.ex @@ -0,0 +1,27 @@ +defmodule API.ApiSpec do + alias OpenApiSpex.{Components, Info, OpenApi, Paths, SecurityScheme, Server} + alias API.{Endpoint, Router} + @behaviour OpenApi + + @impl OpenApi + def spec do + %OpenApi{ + servers: [ + # Populate the Server info from a phoenix endpoint + Server.from_endpoint(Endpoint) + ], + info: %Info{ + title: "Firezone API", + version: "1.0" + }, + # Populate the paths from a phoenix router + paths: Paths.from_router(Router), + components: %Components{ + securitySchemes: %{"authorization" => %SecurityScheme{type: "http", scheme: "bearer"}} + }, + security: [%{"authorization" => []}] + } + # Discover request/response schemas from path specs + |> OpenApiSpex.resolve_schema_modules() + end +end diff --git a/elixir/apps/api/lib/api/controllers/actor_controller.ex b/elixir/apps/api/lib/api/controllers/actor_controller.ex new file mode 100644 index 000000000..e020a780b --- /dev/null +++ b/elixir/apps/api/lib/api/controllers/actor_controller.ex @@ -0,0 +1,128 @@ +defmodule API.ActorController do + use API, :controller + use OpenApiSpex.ControllerSpecs + alias API.Pagination + alias Domain.Actors + + action_fallback API.FallbackController + + tags ["Actors"] + + operation :index, + summary: "List Actors", + parameters: [ + limit: [in: :query, description: "Limit Users returned", type: :integer, example: 10], + page_cursor: [in: :query, description: "Next/Prev page cursor", type: :string] + ], + responses: [ + ok: {"ActorsResponse", "application/json", API.Schemas.Actor.ListResponse} + ] + + # List Actors + def index(conn, params) do + list_opts = Pagination.params_to_list_opts(params) + + with {:ok, actors, metadata} <- Actors.list_actors(conn.assigns.subject, list_opts) do + render(conn, :index, actors: actors, metadata: metadata) + end + end + + operation :show, + summary: "Show Actor", + parameters: [ + id: [ + in: :path, + description: "Actor ID", + type: :string, + example: "00000000-0000-0000-0000-000000000000" + ] + ], + responses: [ + ok: {"ActorResponse", "application/json", API.Schemas.Actor.Response} + ] + + # Show a specific Actor + def show(conn, %{"id" => id}) do + with {:ok, actor} <- Actors.fetch_actor_by_id(id, conn.assigns.subject) do + render(conn, :show, actor: actor) + end + end + + operation :create, + summary: "Create an Actor", + request_body: + {"Actor attributes", "application/json", API.Schemas.Actor.Request, required: true}, + responses: [ + ok: {"ActorResponse", "application/json", API.Schemas.Actor.Response} + ] + + # Create a new Actor + def create(conn, %{"actor" => params}) do + subject = conn.assigns.subject + + with {:ok, actor} <- Actors.create_actor(subject.account, params, subject) do + conn + |> put_status(:created) + |> put_resp_header("location", ~p"/actors/#{actor}") + |> render(:show, actor: actor) + end + end + + def create(_conn, _params) do + {:error, :bad_request} + end + + operation :update, + summary: "Update an Actor", + parameters: [ + id: [ + in: :path, + description: "Actor ID", + type: :string, + example: "00000000-0000-0000-0000-000000000000" + ] + ], + request_body: + {"Actor attributes", "application/json", API.Schemas.Actor.Request, required: true}, + responses: [ + ok: {"ActorResponse", "application/json", API.Schemas.Actor.Response} + ] + + # Update an Actor + def update(conn, %{"id" => id, "actor" => params}) do + subject = conn.assigns.subject + + with {:ok, actor} <- Actors.fetch_actor_by_id(id, subject), + {:ok, actor} <- Actors.update_actor(actor, params, subject) do + render(conn, :show, actor: actor) + end + end + + def update(_conn, _params) do + {:error, :bad_request} + end + + operation :delete, + summary: "Delete an Actor", + parameters: [ + id: [ + in: :path, + description: "Actor ID", + type: :string, + example: "00000000-0000-0000-0000-000000000000" + ] + ], + responses: [ + ok: {"ActorResponse", "application/json", API.Schemas.Actor.Response} + ] + + # Delete an Actor + def delete(conn, %{"id" => id}) do + subject = conn.assigns.subject + + with {:ok, actor} <- Actors.fetch_actor_by_id(id, subject), + {:ok, actor} <- Actors.delete_actor(actor, subject) do + render(conn, :show, actor: actor) + end + end +end diff --git a/elixir/apps/api/lib/api/controllers/actor_group_controller.ex b/elixir/apps/api/lib/api/controllers/actor_group_controller.ex new file mode 100644 index 000000000..6431b02f9 --- /dev/null +++ b/elixir/apps/api/lib/api/controllers/actor_group_controller.ex @@ -0,0 +1,131 @@ +defmodule API.ActorGroupController do + use API, :controller + use OpenApiSpex.ControllerSpecs + alias API.Pagination + alias Domain.Actors + + action_fallback API.FallbackController + + tags ["Actor Groups"] + + operation :index, + summary: "List Actor Groups", + parameters: [ + limit: [in: :query, description: "Limit Actor Groups returned", type: :integer, example: 10], + page_cursor: [in: :query, description: "Next/Prev page cursor", type: :string] + ], + responses: [ + ok: {"Actor Group Response", "application/json", API.Schemas.ActorGroup.ListResponse} + ] + + # List Actor Groups + def index(conn, params) do + list_opts = Pagination.params_to_list_opts(params) + + with {:ok, actor_groups, metadata} <- Actors.list_groups(conn.assigns.subject, list_opts) do + render(conn, :index, actor_groups: actor_groups, metadata: metadata) + end + end + + operation :show, + summary: "Show Actor Group", + parameters: [ + id: [ + in: :path, + description: "Actor Group ID", + type: :string, + example: "00000000-0000-0000-0000-000000000000" + ] + ], + responses: [ + ok: {"Actor Group Response", "application/json", API.Schemas.ActorGroup.Response} + ] + + # Show a specific Actor Group + def show(conn, %{"id" => id}) do + with {:ok, actor_group} <- Actors.fetch_group_by_id(id, conn.assigns.subject) do + render(conn, :show, actor_group: actor_group) + end + end + + operation :create, + summary: "Create Actor Group", + parameters: [], + request_body: + {"Actor Group Attributes", "application/json", API.Schemas.ActorGroup.Request, + required: true}, + responses: [ + ok: {"Actor Group Response", "application/json", API.Schemas.ActorGroup.Response} + ] + + # Create a new Actor Group + def create(conn, %{"actor_group" => params}) do + params = Map.put(params, "type", "static") + + with {:ok, actor_group} <- Actors.create_group(params, conn.assigns.subject) do + conn + |> put_status(:created) + |> put_resp_header("location", ~p"/actor_groups/#{actor_group}") + |> render(:show, actor_group: actor_group) + end + end + + def create(_conn, _params) do + {:error, :bad_request} + end + + operation :update, + summary: "Update a Actor Group", + parameters: [ + id: [ + in: :path, + description: "Actor Group ID", + type: :string, + example: "00000000-0000-0000-0000-000000000000" + ] + ], + request_body: + {"Actor Group Attributes", "application/json", API.Schemas.ActorGroup.Request, + required: true}, + responses: [ + ok: {"Actor Group Response", "application/json", API.Schemas.ActorGroup.Response} + ] + + # Update an Actor Group + def update(conn, %{"id" => id, "actor_group" => params}) do + subject = conn.assigns.subject + + with {:ok, actor_group} <- Actors.fetch_group_by_id(id, subject), + {:ok, actor_group} <- Actors.update_group(actor_group, params, subject) do + render(conn, :show, actor_group: actor_group) + end + end + + def update(_conn, _params) do + {:error, :bad_request} + end + + operation :delete, + summary: "Delete a Actor Group", + parameters: [ + id: [ + in: :path, + description: "Actor Group ID", + type: :string, + example: "00000000-0000-0000-0000-000000000000" + ] + ], + responses: [ + ok: {"Actor Group Response", "application/json", API.Schemas.ActorGroup.Response} + ] + + # Delete an Actor Group + def delete(conn, %{"id" => id}) do + subject = conn.assigns.subject + + with {:ok, actor_group} <- Actors.fetch_group_by_id(id, subject), + {:ok, actor_group} <- Actors.delete_group(actor_group, subject) do + render(conn, :show, actor_group: actor_group) + end + end +end diff --git a/elixir/apps/api/lib/api/controllers/actor_group_json.ex b/elixir/apps/api/lib/api/controllers/actor_group_json.ex new file mode 100644 index 000000000..c2542085a --- /dev/null +++ b/elixir/apps/api/lib/api/controllers/actor_group_json.ex @@ -0,0 +1,28 @@ +defmodule API.ActorGroupJSON do + alias API.Pagination + alias Domain.Actors + + @doc """ + Renders a list of Actor Groups. + """ + def index(%{actor_groups: actor_groups, metadata: metadata}) do + %{ + data: Enum.map(actor_groups, &data/1), + metadata: Pagination.metadata(metadata) + } + end + + @doc """ + Render a single Actor Group + """ + def show(%{actor_group: actor_group}) do + %{data: data(actor_group)} + end + + defp data(%Actors.Group{} = actor_group) do + %{ + id: actor_group.id, + name: actor_group.name + } + end +end diff --git a/elixir/apps/api/lib/api/controllers/actor_group_membership_controller.ex b/elixir/apps/api/lib/api/controllers/actor_group_membership_controller.ex new file mode 100644 index 000000000..ff3b9e429 --- /dev/null +++ b/elixir/apps/api/lib/api/controllers/actor_group_membership_controller.ex @@ -0,0 +1,135 @@ +defmodule API.ActorGroupMembershipController do + use API, :controller + use OpenApiSpex.ControllerSpecs + alias API.Pagination + alias Domain.Actors + + action_fallback API.FallbackController + + tags ["Actor Group Memberships"] + + operation :index, + summary: "List Actor Group Memberships", + parameters: [ + actor_group_id: [ + in: :path, + description: "Actor Group ID", + example: "00000000-0000-0000-0000-000000000000" + ], + limit: [ + in: :query, + description: "Limit Actor Group Memberships returned", + type: :integer, + example: 10 + ], + page_cursor: [in: :query, description: "Next/Prev page cursor", type: :string] + ], + responses: [ + ok: + {"Actor Group Membership Response", "application/json", + API.Schemas.ActorGroupMembership.ListResponse} + ] + + # List members for a given Actor Group + def index(conn, %{"actor_group_id" => actor_group_id} = params) do + list_opts = + Pagination.params_to_list_opts(params) + |> Keyword.put(:filter, group_id: actor_group_id) + + with {:ok, actors, metadata} <- Actors.list_actors(conn.assigns.subject, list_opts) do + render(conn, :index, actors: actors, metadata: metadata) + end + end + + operation :update_put, + summary: "Update Actor Group Memberships", + parameters: [ + actor_group_id: [ + in: :path, + description: "Actor Group ID", + example: "00000000-0000-0000-0000-000000000000" + ] + ], + request_body: + {"Actor Group Membership Attributes", "application/json", + API.Schemas.ActorGroupMembership.PutRequest, required: true}, + responses: [ + ok: + {"Actor Group Membership Response", "application/json", + API.Schemas.ActorGroupMembership.MembershipResponse} + ] + + def update_put( + conn, + %{"actor_group_id" => actor_group_id, "memberships" => attrs} + ) do + subject = conn.assigns.subject + preload = [:memberships] + filter = [deleted?: false, editable?: true] + + with {:ok, group} <- + Actors.fetch_group_by_id(actor_group_id, subject, preload: preload, filter: filter), + {:ok, group} <- Actors.update_group(group, %{memberships: attrs}, subject) do + render(conn, :memberships, memberships: group.memberships) + end + end + + def update_put(_conn, _params) do + {:error, :bad_request} + end + + operation :update_patch, + summary: "Update an Actor Group Membership", + parameters: [ + actor_group_id: [ + in: :path, + description: "Actor Group ID", + example: "00000000-0000-0000-0000-000000000000" + ] + ], + request_body: + {"Actor Group Membership Attributes", "application/json", + API.Schemas.ActorGroupMembership.PatchRequest, required: true}, + responses: [ + ok: + {"Actor Group Membership Response", "application/json", + API.Schemas.ActorGroupMembership.MembershipResponse} + ] + + # Update Actor Group Memberships + def update_patch( + conn, + %{"actor_group_id" => actor_group_id, "memberships" => params} + ) do + add = Map.get(params, "add", []) + remove = Map.get(params, "remove", []) + subject = conn.assigns.subject + preload = [:memberships] + filter = [deleted?: false, editable?: true] + + with {:ok, group} <- + Actors.fetch_group_by_id(actor_group_id, subject, preload: preload, filter: filter), + membership_attrs <- prepare_membership_attrs(group, add, remove), + {:ok, group} <- Actors.update_group(group, %{memberships: membership_attrs}, subject) do + render(conn, :memberships, memberships: group.memberships) + end + end + + def update_patch(_conn, _params) do + {:error, :bad_request} + end + + defp prepare_membership_attrs(group, add, remove) do + to_add = MapSet.new(add) |> MapSet.reject(&(String.trim(&1) == "")) + to_remove = MapSet.new(remove) |> MapSet.reject(&(String.trim(&1) == "")) + member_ids = Enum.map(group.memberships, & &1.actor_id) |> MapSet.new() + + membership_ids = + MapSet.difference(member_ids, to_remove) + |> MapSet.union(to_add) + + if MapSet.size(membership_ids) == 0, + do: [], + else: Enum.map(membership_ids, &%{actor_id: &1}) + end +end diff --git a/elixir/apps/api/lib/api/controllers/actor_group_membership_json.ex b/elixir/apps/api/lib/api/controllers/actor_group_membership_json.ex new file mode 100644 index 000000000..daeec3689 --- /dev/null +++ b/elixir/apps/api/lib/api/controllers/actor_group_membership_json.ex @@ -0,0 +1,30 @@ +defmodule API.ActorGroupMembershipJSON do + alias API.Pagination + alias Domain.Actors + + @doc """ + Renders a list of Actors. + """ + def index(%{actors: actors, metadata: metadata}) do + %{ + data: Enum.map(actors, &data/1), + metadata: Pagination.metadata(metadata) + } + end + + @doc """ + Renders a list of Actor IDs for an Actor Group + """ + def memberships(%{memberships: memberships}) do + actor_ids = for(membership <- memberships, do: membership.actor_id) + %{data: %{actor_ids: actor_ids}} + end + + defp data(%Actors.Actor{} = actor) do + %{ + id: actor.id, + name: actor.name, + type: actor.type + } + end +end diff --git a/elixir/apps/api/lib/api/controllers/actor_json.ex b/elixir/apps/api/lib/api/controllers/actor_json.ex new file mode 100644 index 000000000..2400e8187 --- /dev/null +++ b/elixir/apps/api/lib/api/controllers/actor_json.ex @@ -0,0 +1,29 @@ +defmodule API.ActorJSON do + alias API.Pagination + alias Domain.Actors + + @doc """ + Renders a list of Actors. + """ + def index(%{actors: actors, metadata: metadata}) do + %{ + data: Enum.map(actors, &data/1), + metadata: Pagination.metadata(metadata) + } + end + + @doc """ + Render a single Actor + """ + def show(%{actor: actor}) do + %{data: data(actor)} + end + + defp data(%Actors.Actor{} = actor) do + %{ + id: actor.id, + name: actor.name, + type: actor.type + } + end +end diff --git a/elixir/apps/api/lib/api/controllers/changeset_json.ex b/elixir/apps/api/lib/api/controllers/changeset_json.ex new file mode 100644 index 000000000..0f970a861 --- /dev/null +++ b/elixir/apps/api/lib/api/controllers/changeset_json.ex @@ -0,0 +1,16 @@ +defmodule API.ChangesetJSON do + def error(%{status: status, changeset: changeset}) do + %{ + error: %{ + reason: Plug.Conn.Status.reason_phrase(status), + validation_errors: Ecto.Changeset.traverse_errors(changeset, &translate_error/1) + } + } + end + + defp translate_error({msg, opts}) do + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end) + end) + end +end diff --git a/elixir/apps/api/lib/api/controllers/error_json.ex b/elixir/apps/api/lib/api/controllers/error_json.ex new file mode 100644 index 000000000..67228173d --- /dev/null +++ b/elixir/apps/api/lib/api/controllers/error_json.ex @@ -0,0 +1,5 @@ +defmodule API.ErrorJSON do + def render(template, _assigns) do + %{error: %{reason: Phoenix.Controller.status_message_from_template(template)}} + end +end diff --git a/elixir/apps/api/lib/api/controllers/example_controller.ex b/elixir/apps/api/lib/api/controllers/example_controller.ex deleted file mode 100644 index b4a9ddf9b..000000000 --- a/elixir/apps/api/lib/api/controllers/example_controller.ex +++ /dev/null @@ -1,9 +0,0 @@ -defmodule API.ExampleController do - use API, :controller - - def echo(conn, params) do - conn - |> put_resp_content_type("application/json") - |> send_resp(200, Jason.encode!(params)) - end -end diff --git a/elixir/apps/api/lib/api/controllers/fallback_controller.ex b/elixir/apps/api/lib/api/controllers/fallback_controller.ex new file mode 100644 index 000000000..f08047aa3 --- /dev/null +++ b/elixir/apps/api/lib/api/controllers/fallback_controller.ex @@ -0,0 +1,38 @@ +defmodule API.FallbackController do + use Phoenix.Controller + + def call(conn, {:error, :not_found}) do + conn + |> put_status(:not_found) + |> put_view(json: API.ErrorJSON) + |> render(:"404") + end + + def call(conn, {:error, :unauthorized}) do + conn + |> put_status(:unauthorized) + |> put_view(json: API.ErrorJSON) + |> render(:"401") + end + + def call(conn, {:error, :bad_request}) do + conn + |> put_status(:bad_request) + |> put_view(json: API.ErrorJSON) + |> render(:"400") + end + + def call(conn, {:error, :unprocessable_entity}) do + conn + |> put_status(:unprocessable_entity) + |> put_view(json: API.ErrorJSON) + |> render(:"422") + end + + def call(conn, {:error, %Ecto.Changeset{} = changeset}) do + conn + |> put_status(:unprocessable_entity) + |> put_view(json: API.ChangesetJSON) + |> render(:error, status: 422, changeset: changeset) + end +end diff --git a/elixir/apps/api/lib/api/controllers/gateway_controller.ex b/elixir/apps/api/lib/api/controllers/gateway_controller.ex new file mode 100644 index 000000000..12211cb37 --- /dev/null +++ b/elixir/apps/api/lib/api/controllers/gateway_controller.ex @@ -0,0 +1,96 @@ +defmodule API.GatewayController do + use API, :controller + use OpenApiSpex.ControllerSpecs + alias API.Pagination + alias Domain.Gateways + + action_fallback API.FallbackController + + tags ["Gateways"] + + operation :index, + summary: "List Gateways", + parameters: [ + gateway_group_id: [ + in: :path, + description: "Gateway Group ID", + type: :string, + example: "00000000-0000-0000-0000-000000000000" + ], + limit: [in: :query, description: "Limit Gateways returned", type: :integer, example: 10], + page_cursor: [in: :query, description: "Next/Prev page cursor", type: :string] + ], + responses: [ + ok: {"Gateway Response", "application/json", API.Schemas.Gateway.ListResponse} + ] + + # List Gateways + def index(conn, params) do + list_opts = + params + |> Pagination.params_to_list_opts() + |> Keyword.put(:preload, :online?) + + with {:ok, gateways, metadata} <- Gateways.list_gateways(conn.assigns.subject, list_opts) do + render(conn, :index, gateways: gateways, metadata: metadata) + end + end + + operation :show, + summary: "Show Gateway", + parameters: [ + gateway_group_id: [ + in: :path, + description: "Gateway Group ID", + type: :string, + example: "00000000-0000-0000-0000-000000000000" + ], + id: [ + in: :path, + description: "Gateway ID", + type: :string, + example: "00000000-0000-0000-0000-000000000000" + ] + ], + responses: [ + ok: {"Gateway Response", "application/json", API.Schemas.Gateway.Response} + ] + + # Show a specific Gateway + def show(conn, %{"id" => id}) do + with {:ok, gateway} <- + Gateways.fetch_gateway_by_id(id, conn.assigns.subject, preload: :online?) do + render(conn, :show, gateway: gateway) + end + end + + operation :delete, + summary: "Delete a Gateway", + parameters: [ + gateway_group_id: [ + in: :path, + description: "Gateway Group ID", + type: :string, + example: "00000000-0000-0000-0000-000000000000" + ], + id: [ + in: :path, + description: "Gateway ID", + type: :string, + example: "00000000-0000-0000-0000-000000000000" + ] + ], + responses: [ + ok: {"Gateway Response", "application/json", API.Schemas.Gateway.Response} + ] + + # Delete a Gateway + def delete(conn, %{"id" => id}) do + subject = conn.assigns.subject + + with {:ok, gateway} <- Gateways.fetch_gateway_by_id(id, subject), + {:ok, gateway} <- Gateways.delete_gateway(gateway, subject) do + render(conn, :show, gateway: gateway) + end + end +end diff --git a/elixir/apps/api/lib/api/controllers/gateway_group_controller.ex b/elixir/apps/api/lib/api/controllers/gateway_group_controller.ex new file mode 100644 index 000000000..78db39120 --- /dev/null +++ b/elixir/apps/api/lib/api/controllers/gateway_group_controller.ex @@ -0,0 +1,211 @@ +defmodule API.GatewayGroupController do + use API, :controller + use OpenApiSpex.ControllerSpecs + alias API.Pagination + alias Domain.{Gateways, Tokens} + + action_fallback API.FallbackController + + tags ["Gateway Groups"] + + operation :index, + summary: "List Gateway Groups", + parameters: [ + limit: [ + in: :query, + description: "Limit Gateway Groups returned", + type: :integer, + example: 10 + ], + page_cursor: [in: :query, description: "Next/Prev page cursor", type: :string] + ], + responses: [ + ok: {"Gateway Group Response", "application/json", API.Schemas.GatewayGroup.ListResponse} + ] + + # List Gateway Groups / Sites + def index(conn, params) do + list_opts = Pagination.params_to_list_opts(params) + + with {:ok, gateway_groups, metadata} <- Gateways.list_groups(conn.assigns.subject, list_opts) do + render(conn, :index, gateway_groups: gateway_groups, metadata: metadata) + end + end + + operation :show, + summary: "Show Gateway Group", + parameters: [ + id: [ + in: :path, + description: "Gateway Group ID", + type: :string, + example: "00000000-0000-0000-0000-000000000000" + ] + ], + responses: [ + ok: {"Gateway Group Response", "application/json", API.Schemas.GatewayGroup.Response} + ] + + # Show a specific Gateway Group / Site + def show(conn, %{"id" => id}) do + with {:ok, gateway_group} <- Gateways.fetch_group_by_id(id, conn.assigns.subject) do + render(conn, :show, gateway_group: gateway_group) + end + end + + operation :create, + summary: "Create Gateway Group", + parameters: [], + request_body: + {"Gateway Group Attributes", "application/json", API.Schemas.GatewayGroup.Request, + required: true}, + responses: [ + ok: {"Gateway Group Response", "application/json", API.Schemas.GatewayGroup.Response} + ] + + # Create a new Gateway Group / Site + def create(conn, %{"gateway_group" => params}) do + with {:ok, gateway_group} <- Gateways.create_group(params, conn.assigns.subject) do + conn + |> put_status(:created) + |> put_resp_header("location", ~p"/gateway_groups/#{gateway_group}") + |> render(:show, gateway_group: gateway_group) + end + end + + def create(_conn, _params) do + {:error, :bad_request} + end + + operation :update, + summary: "Update a Gateway Group", + request_body: + {"Gateway Group Attributes", "application/json", API.Schemas.GatewayGroup.Request, + required: true}, + responses: [ + ok: {"Gateway Group Response", "application/json", API.Schemas.GatewayGroup.Response} + ] + + # Update a Gateway Group / Site + def update(conn, %{"id" => id, "gateway_group" => params}) do + subject = conn.assigns.subject + + with {:ok, gateway_group} <- Gateways.fetch_group_by_id(id, subject), + {:ok, gateway_group} <- Gateways.update_group(gateway_group, params, subject) do + render(conn, :show, gateway_group: gateway_group) + end + end + + def update(_conn, _params) do + {:error, :bad_request} + end + + operation :delete, + summary: "Delete a Gateway Group", + parameters: [ + id: [ + in: :path, + description: "Gateway Group ID", + type: :string, + example: "00000000-0000-0000-0000-000000000000" + ] + ], + responses: [ + ok: {"Gateway Group Response", "application/json", API.Schemas.GatewayGroup.Response} + ] + + # Delete a Gateway Group / Site + def delete(conn, %{"id" => id}) do + subject = conn.assigns.subject + + with {:ok, gateway_group} <- Gateways.fetch_group_by_id(id, subject), + {:ok, gateway_group} <- Gateways.delete_group(gateway_group, subject) do + render(conn, :show, gateway_group: gateway_group) + end + end + + operation :create_token, + summary: "Create a Gateway Token", + parameters: [ + gateway_group_id: [ + in: :path, + description: "Gateway Group ID", + type: :string, + example: "00000000-0000-0000-0000-000000000000" + ] + ], + responses: [ + ok: + {"New Gateway Token Response", "application/json", API.Schemas.GatewayGroupToken.NewToken} + ] + + # Create a Gateway Group Token (used for deploying a gateway) + def create_token(conn, %{"gateway_group_id" => gateway_group_id}) do + subject = conn.assigns.subject + + with {:ok, gateway_group} <- Gateways.fetch_group_by_id(gateway_group_id, subject), + {:ok, gateway_token, encoded_token} <- + Gateways.create_token(gateway_group, %{}, subject) do + conn + |> put_status(:created) + |> render(:token, gateway_token: gateway_token, encoded_token: encoded_token) + end + end + + operation :delete_token, + summary: "Delete a Gateway Token", + parameters: [ + gateway_group_id: [ + in: :path, + description: "Gateway Group ID", + type: :string, + example: "00000000-0000-0000-0000-000000000000" + ], + id: [ + in: :path, + description: "Gateway Token ID", + type: :string, + example: "00000000-0000-0000-0000-000000000000" + ] + ], + responses: [ + ok: + {"Deleted Gateway Token Response", "application/json", + API.Schemas.GatewayGroupToken.DeletedToken} + ] + + # Delete/Revoke a Gateway Group Token + def delete_token(conn, %{"gateway_group_id" => _gateway_group_id, "id" => token_id}) do + subject = conn.assigns.subject + + with {:ok, token} <- Tokens.fetch_token_by_id(token_id, subject), + {:ok, token} <- Tokens.delete_token(token, subject) do + render(conn, :deleted_token, gateway_token: token) + end + end + + operation :delete_all_tokens, + summary: "Delete all Gateway Tokens for a given Gateway Group", + parameters: [ + gateway_group_id: [ + in: :path, + description: "Gateway Group ID", + type: :string, + example: "00000000-0000-0000-0000-000000000000" + ] + ], + responses: [ + ok: + {"Deleted Gateway Tokens Response", "application/json", + API.Schemas.GatewayGroupToken.DeletedTokens} + ] + + def delete_all_tokens(conn, %{"gateway_group_id" => gateway_group_id}) do + subject = conn.assigns.subject + + with {:ok, gateway_group} <- Gateways.fetch_group_by_id(gateway_group_id, subject), + {:ok, deleted_tokens} <- Tokens.delete_tokens_for(gateway_group, subject) do + render(conn, :deleted_tokens, %{tokens: deleted_tokens}) + end + end +end diff --git a/elixir/apps/api/lib/api/controllers/gateway_group_json.ex b/elixir/apps/api/lib/api/controllers/gateway_group_json.ex new file mode 100644 index 000000000..50289fbe2 --- /dev/null +++ b/elixir/apps/api/lib/api/controllers/gateway_group_json.ex @@ -0,0 +1,58 @@ +defmodule API.GatewayGroupJSON do + alias API.Pagination + alias Domain.Gateways + + @doc """ + Renders a list of Sites / Gateway Groups. + """ + def index(%{gateway_groups: gateway_groups, metadata: metadata}) do + %{ + data: Enum.map(gateway_groups, &data/1), + metadata: Pagination.metadata(metadata) + } + end + + @doc """ + Render a single Site / Gateway Group + """ + def show(%{gateway_group: group}) do + %{data: data(group)} + end + + @doc """ + Render a Gateway Group Token + """ + def token(%{gateway_token: token, encoded_token: encoded_token}) do + %{ + data: %{ + id: token.id, + token: encoded_token + } + } + end + + @doc """ + Render a deleted Gateway Group Token + """ + def deleted_token(%{gateway_token: token}) do + %{ + data: %{ + id: token.id + } + } + end + + @doc """ + Render all deleted Gateway Group Tokens + """ + def deleted_tokens(%{tokens: tokens}) do + %{data: for(token <- tokens, do: %{id: token.id})} + end + + defp data(%Gateways.Group{} = group) do + %{ + id: group.id, + name: group.name + } + end +end diff --git a/elixir/apps/api/lib/api/controllers/gateway_json.ex b/elixir/apps/api/lib/api/controllers/gateway_json.ex new file mode 100644 index 000000000..693d4d989 --- /dev/null +++ b/elixir/apps/api/lib/api/controllers/gateway_json.ex @@ -0,0 +1,31 @@ +defmodule API.GatewayJSON do + alias API.Pagination + alias Domain.Gateways + + @doc """ + Renders a list of Gateways. + """ + def index(%{gateways: gateways, metadata: metadata}) do + %{ + data: Enum.map(gateways, &data/1), + metadata: Pagination.metadata(metadata) + } + end + + @doc """ + Render a single Gateway + """ + def show(%{gateway: gateway}) do + %{data: data(gateway)} + end + + defp data(%Gateways.Gateway{} = gateway) do + %{ + id: gateway.id, + name: gateway.name, + ipv4: gateway.ipv4, + ipv6: gateway.ipv6, + online: gateway.online? + } + end +end diff --git a/elixir/apps/api/lib/api/controllers/identity_controller.ex b/elixir/apps/api/lib/api/controllers/identity_controller.ex new file mode 100644 index 000000000..a782928eb --- /dev/null +++ b/elixir/apps/api/lib/api/controllers/identity_controller.ex @@ -0,0 +1,136 @@ +defmodule API.IdentityController do + use API, :controller + use OpenApiSpex.ControllerSpecs + alias API.Pagination + alias Domain.Auth + + action_fallback API.FallbackController + + tags ["Identities"] + + operation :index, + summary: "List Identities for an Actor", + parameters: [ + actor_id: [in: :path, description: "Actor ID", type: :string], + limit: [in: :query, description: "Limit Identities returned", type: :integer, example: 10], + page_cursor: [in: :query, description: "Next/Prev page cursor", type: :string] + ], + responses: [ + ok: {"Identity List Response", "application/json", API.Schemas.Identity.ListResponse} + ] + + # List Identities + def index(conn, %{"actor_id" => actor_id} = params) do + subject = conn.assigns.subject + list_opts = Pagination.params_to_list_opts(params) + + with {:ok, actor} <- Domain.Actors.fetch_actor_by_id(actor_id, subject), + {:ok, identities, metadata} <- Auth.list_identities_for(actor, subject, list_opts) do + render(conn, :index, identities: identities, metadata: metadata) + end + end + + operation :create, + summary: "Create an Identity for an Actor", + parameters: [ + actor_id: [in: :path, description: "Actor ID", type: :string] + ], + request_body: + {"Identity Attributes", "application/json", API.Schemas.Identity.Request, required: true}, + responses: [ + ok: {"Identity Response", "application/json", API.Schemas.Identity.Response} + ] + + # Create an Identity + def create(conn, %{ + "actor_id" => actor_id, + "provider_id" => provider_id, + "identity" => params + }) do + subject = conn.assigns.subject + + params = + Map.put_new( + params, + "provider_identifier_confirmation", + Map.get(params, "provider_identifier") + ) + + with {:ok, actor} <- Domain.Actors.fetch_actor_by_id(actor_id, subject), + {:ok, provider} <- Auth.fetch_provider_by_id(provider_id, subject), + {:provider_check, true} <- valid_provider?(provider), + {:ok, identity} <- Auth.create_identity(actor, provider, params) do + conn + |> put_status(:created) + |> put_resp_header("location", ~p"/actors/#{actor_id}/identities/#{identity.id}") + |> render(:show, identity: identity) + else + {:provider_check, _false} -> {:error, :unprocessable_entity} + other -> other + end + end + + def create(_conn, _params) do + {:error, :bad_request} + end + + operation :show, + summary: "Show Identity", + parameters: [ + id: [ + in: :path, + description: "Identity ID", + type: :string, + example: "00000000-0000-0000-0000-000000000000" + ] + ], + responses: [ + ok: {"Identity Response", "application/json", API.Schemas.Identity.Response} + ] + + # Show a specific Identity + def show(conn, %{"id" => id}) do + with {:ok, identity} <- Auth.fetch_identity_by_id(id, conn.assigns.subject) do + render(conn, :show, identity: identity) + end + end + + operation :delete, + summary: "Delete an Identity", + parameters: [ + actor_id: [ + in: :path, + description: "Actor ID", + type: :string, + example: "00000000-0000-0000-0000-000000000000" + ], + id: [ + in: :path, + description: "Identity ID", + type: :string, + example: "00000000-0000-0000-0000-000000000000" + ] + ], + responses: [ + ok: {"Identity Response", "application/json", API.Schemas.Identity.Response} + ] + + # Delete an Identity + def delete(conn, %{"id" => id}) do + subject = conn.assigns.subject + + with {:ok, identity} <- Auth.fetch_identity_by_id(id, subject), + {:ok, identity} <- Auth.delete_identity(identity, subject) do + render(conn, :show, identity: identity) + end + end + + defp valid_provider?(provider) do + valid? = + Auth.fetch_provider_capabilities!(provider) + |> Keyword.fetch!(:provisioners) + |> Enum.member?(:manual) + + {:provider_check, valid?} + end +end diff --git a/elixir/apps/api/lib/api/controllers/identity_json.ex b/elixir/apps/api/lib/api/controllers/identity_json.ex new file mode 100644 index 000000000..428500a73 --- /dev/null +++ b/elixir/apps/api/lib/api/controllers/identity_json.ex @@ -0,0 +1,30 @@ +defmodule API.IdentityJSON do + alias API.Pagination + alias Domain.Auth.Identity + + @doc """ + Renders a list of Identities. + """ + def index(%{identities: identities, metadata: metadata}) do + %{ + data: Enum.map(identities, &data/1), + metadata: Pagination.metadata(metadata) + } + end + + @doc """ + Render a single Identity + """ + def show(%{identity: identity}) do + %{data: data(identity)} + end + + defp data(%Identity{} = identity) do + %{ + id: identity.id, + actor_id: identity.actor_id, + provider_id: identity.provider_id, + provider_identifier: identity.provider_identifier + } + end +end diff --git a/elixir/apps/api/lib/api/controllers/identity_provider_controller.ex b/elixir/apps/api/lib/api/controllers/identity_provider_controller.ex new file mode 100644 index 000000000..e0297aa08 --- /dev/null +++ b/elixir/apps/api/lib/api/controllers/identity_provider_controller.ex @@ -0,0 +1,81 @@ +defmodule API.IdentityProviderController do + use API, :controller + use OpenApiSpex.ControllerSpecs + alias API.Pagination + alias Domain.Auth + + action_fallback API.FallbackController + + tags ["Identity Providers"] + + operation :index, + summary: "List Identity Providers", + parameters: [ + limit: [ + in: :query, + description: "Limit Identity Providers returned", + type: :integer, + example: 10 + ], + page_cursor: [in: :query, description: "Next/Prev page cursor", type: :string] + ], + responses: [ + ok: + {"Identity Provider Response", "application/json", + API.Schemas.IdentityProvider.ListResponse} + ] + + def index(conn, params) do + list_opts = Pagination.params_to_list_opts(params) + + with {:ok, identity_providers, metadata} <- + Auth.list_providers(conn.assigns.subject, list_opts) do + render(conn, :index, identity_providers: identity_providers, metadata: metadata) + end + end + + operation :show, + summary: "Show Identity Provider", + parameters: [ + id: [ + in: :path, + description: "Identity Provider ID", + type: :string, + example: "00000000-0000-0000-0000-000000000000" + ] + ], + responses: [ + ok: + {"Identity Provider Response", "application/json", API.Schemas.IdentityProvider.Response} + ] + + def show(conn, %{"id" => id}) do + with {:ok, identity_provider} <- Auth.fetch_provider_by_id(id, conn.assigns.subject) do + render(conn, :show, identity_provider: identity_provider) + end + end + + operation :delete, + summary: "Delete a Identity Provider", + parameters: [ + id: [ + in: :path, + description: "Identity Provider ID", + type: :string, + example: "00000000-0000-0000-0000-000000000000" + ] + ], + responses: [ + ok: + {"Identity Provider Response", "application/json", API.Schemas.IdentityProvider.Response} + ] + + def delete(conn, %{"id" => id}) do + subject = conn.assigns.subject + + with {:ok, identity_provider} <- Auth.fetch_provider_by_id(id, subject), + {:ok, identity_provider} <- Auth.delete_provider(identity_provider, subject) do + render(conn, :show, identity_provider: identity_provider) + end + end +end diff --git a/elixir/apps/api/lib/api/controllers/identity_provider_json.ex b/elixir/apps/api/lib/api/controllers/identity_provider_json.ex new file mode 100644 index 000000000..5aeeacefa --- /dev/null +++ b/elixir/apps/api/lib/api/controllers/identity_provider_json.ex @@ -0,0 +1,28 @@ +defmodule API.IdentityProviderJSON do + alias API.Pagination + alias Domain.Auth.Provider + + @doc """ + Renders a list of Identity Providers. + """ + def index(%{identity_providers: identity_providers, metadata: metadata}) do + %{ + data: Enum.map(identity_providers, &data/1), + metadata: Pagination.metadata(metadata) + } + end + + @doc """ + Renders a single Identity Provider. + """ + def show(%{identity_provider: identity_provider}) do + %{data: data(identity_provider)} + end + + defp data(%Provider{} = provider) do + %{ + id: provider.id, + name: provider.name + } + end +end diff --git a/elixir/apps/api/lib/api/controllers/pagination.ex b/elixir/apps/api/lib/api/controllers/pagination.ex new file mode 100644 index 000000000..249fcfe9f --- /dev/null +++ b/elixir/apps/api/lib/api/controllers/pagination.ex @@ -0,0 +1,35 @@ +defmodule API.Pagination do + alias LoggerJSON.Formatter.Metadata + alias Domain.Repo.Paginator.Metadata + + def params_to_list_opts(params) do + [ + page: params_to_page(params) + ] + end + + def metadata(%Metadata{} = metadata) do + %{ + count: metadata.count, + limit: metadata.limit, + next_page: metadata.next_page_cursor, + prev_page: metadata.previous_page_cursor + } + end + + defp params_to_page(%{"limit" => limit, "page_cursor" => cursor}) do + [cursor: cursor, limit: String.to_integer(limit)] + end + + defp params_to_page(%{"limit" => limit}) do + [limit: String.to_integer(limit)] + end + + defp params_to_page(%{"page_cursor" => cursor}) do + [cursor: cursor] + end + + defp params_to_page(_params) do + [] + end +end diff --git a/elixir/apps/api/lib/api/controllers/policy_controller.ex b/elixir/apps/api/lib/api/controllers/policy_controller.ex new file mode 100644 index 000000000..eba7b813e --- /dev/null +++ b/elixir/apps/api/lib/api/controllers/policy_controller.ex @@ -0,0 +1,119 @@ +defmodule API.PolicyController do + use API, :controller + use OpenApiSpex.ControllerSpecs + alias API.Pagination + alias Domain.Policies + + action_fallback API.FallbackController + + tags ["Policies"] + + operation :index, + summary: "List Policies", + parameters: [ + limit: [in: :query, description: "Limit Policies returned", type: :integer, example: 10], + page_cursor: [in: :query, description: "Next/Prev page cursor", type: :string] + ], + responses: [ + ok: {"Policy Response", "application/json", API.Schemas.Policy.ListResponse} + ] + + # List Policies + def index(conn, params) do + list_opts = Pagination.params_to_list_opts(params) + + with {:ok, policies, metadata} <- Policies.list_policies(conn.assigns.subject, list_opts) do + render(conn, :index, policies: policies, metadata: metadata) + end + end + + operation :show, + summary: "Show Policy", + parameters: [ + id: [ + in: :path, + description: "Policy ID", + type: :string, + example: "00000000-0000-0000-0000-000000000000" + ] + ], + responses: [ + ok: {"Policy Response", "application/json", API.Schemas.Policy.Response} + ] + + # Show a specific Policy + def show(conn, %{"id" => id}) do + with {:ok, policy} <- Policies.fetch_policy_by_id(id, conn.assigns.subject) do + render(conn, :show, policy: policy) + end + end + + operation :create, + summary: "Create Policy", + parameters: [], + request_body: + {"Policy Attributes", "application/json", API.Schemas.Policy.Request, required: true}, + responses: [ + ok: {"Policy Response", "application/json", API.Schemas.Policy.Response} + ] + + # Create a new Policy + def create(conn, %{"policy" => params}) do + with {:ok, policy} <- Policies.create_policy(params, conn.assigns.subject) do + conn + |> put_status(:created) + |> put_resp_header("location", ~p"/policies/#{policy}") + |> render(:show, policy: policy) + end + end + + def create(_conn, _params) do + {:error, :bad_request} + end + + operation :update, + summary: "Update a Policy", + request_body: + {"Policy Attributes", "application/json", API.Schemas.Policy.Request, required: true}, + responses: [ + ok: {"Policy Response", "application/json", API.Schemas.Policy.Response} + ] + + # Update a Policy + def update(conn, %{"id" => id, "policy" => params}) do + subject = conn.assigns.subject + + with {:ok, policy} <- Policies.fetch_policy_by_id(id, subject), + {:ok, policy} <- Policies.update_policy(policy, params, subject) do + render(conn, :show, policy: policy) + end + end + + def update(_conn, _params) do + {:error, :bad_request} + end + + operation :delete, + summary: "Delete a Policy", + parameters: [ + id: [ + in: :path, + description: "Policy ID", + type: :string, + example: "00000000-0000-0000-0000-000000000000" + ] + ], + responses: [ + ok: {"Policy Response", "application/json", API.Schemas.Policy.Response} + ] + + # Delete a Policy + def delete(conn, %{"id" => id}) do + subject = conn.assigns.subject + + with {:ok, policy} <- Policies.fetch_policy_by_id(id, subject), + {:ok, policy} <- Policies.delete_policy(policy, subject) do + render(conn, :show, policy: policy) + end + end +end diff --git a/elixir/apps/api/lib/api/controllers/policy_json.ex b/elixir/apps/api/lib/api/controllers/policy_json.ex new file mode 100644 index 000000000..588ab8e07 --- /dev/null +++ b/elixir/apps/api/lib/api/controllers/policy_json.ex @@ -0,0 +1,30 @@ +defmodule API.PolicyJSON do + alias API.Pagination + alias Domain.Policies + + @doc """ + Renders a list of Policies. + """ + def index(%{policies: policies, metadata: metadata}) do + %{ + data: Enum.map(policies, &data/1), + metadata: Pagination.metadata(metadata) + } + end + + @doc """ + Render a single Policy + """ + def show(%{policy: policy}) do + %{data: data(policy)} + end + + defp data(%Policies.Policy{} = policy) do + %{ + id: policy.id, + actor_group_id: policy.actor_group_id, + resource_id: policy.resource_id, + description: policy.description + } + end +end diff --git a/elixir/apps/api/lib/api/controllers/resource_controller.ex b/elixir/apps/api/lib/api/controllers/resource_controller.ex new file mode 100644 index 000000000..7200c2f39 --- /dev/null +++ b/elixir/apps/api/lib/api/controllers/resource_controller.ex @@ -0,0 +1,122 @@ +defmodule API.ResourceController do + use API, :controller + use OpenApiSpex.ControllerSpecs + alias API.Pagination + alias Domain.Resources + + action_fallback API.FallbackController + + tags ["Resources"] + + operation :index, + summary: "List Resources", + parameters: [ + limit: [in: :query, description: "Limit Resources returned", type: :integer, example: 10], + page_cursor: [in: :query, description: "Next/Prev page cursor", type: :string] + ], + responses: [ + ok: {"Resource Response", "application/json", API.Schemas.Resource.ListResponse} + ] + + def index(conn, params) do + list_opts = Pagination.params_to_list_opts(params) + + with {:ok, resources, metadata} <- + Resources.list_resources(conn.assigns.subject, list_opts) do + render(conn, :index, resources: resources, metadata: metadata) + end + end + + operation :show, + summary: "Show Resource", + parameters: [ + id: [ + in: :path, + description: "Resource ID", + type: :string, + example: "00000000-0000-0000-0000-000000000000" + ] + ], + responses: [ + ok: {"Resource Response", "application/json", API.Schemas.Resource.Response} + ] + + def show(conn, %{"id" => id}) do + with {:ok, resource} <- Resources.fetch_resource_by_id(id, conn.assigns.subject) do + render(conn, :show, resource: resource) + end + end + + operation :create, + summary: "Create Resource", + parameters: [], + request_body: + {"Resource Attributes", "application/json", API.Schemas.Resource.Request, required: true}, + responses: [ + ok: {"Resource Response", "application/json", API.Schemas.Resource.Response} + ] + + def create(conn, %{"resource" => params}) do + attrs = set_param_defaults(params) + + with {:ok, resource} <- Resources.create_resource(attrs, conn.assigns.subject) do + conn + |> put_status(:created) + |> put_resp_header("location", ~p"/resources/#{resource}") + |> render(:show, resource: resource) + end + end + + def create(_conn, _params) do + {:error, :bad_request} + end + + operation :update, + summary: "Update Resource", + request_body: + {"Resource Attributes", "application/json", API.Schemas.Resource.Request, required: true}, + responses: [ + ok: {"Resource Response", "application/json", API.Schemas.Resource.Response} + ] + + def update(conn, %{"id" => id, "resource" => params}) do + subject = conn.assigns.subject + attrs = set_param_defaults(params) + + with {:ok, resource} <- Resources.fetch_resource_by_id(id, subject), + {:ok, resource} <- Resources.update_resource(resource, attrs, subject) do + render(conn, :show, resource: resource) + end + end + + def update(_conn, _params) do + {:error, :bad_request} + end + + operation :delete, + summary: "Delete Resource", + parameters: [ + id: [ + in: :path, + description: "Resource ID", + type: :string, + example: "00000000-0000-0000-0000-000000000000" + ] + ], + responses: [ + ok: {"Resource Response", "application/json", API.Schemas.Resource.Response} + ] + + def delete(conn, %{"id" => id}) do + subject = conn.assigns.subject + + with {:ok, resource} <- Resources.fetch_resource_by_id(id, subject), + {:ok, resource} <- Resources.delete_resource(resource, subject) do + render(conn, :show, resource: resource) + end + end + + defp set_param_defaults(params) do + Map.put_new(params, "filters", %{}) + end +end diff --git a/elixir/apps/api/lib/api/controllers/resource_json.ex b/elixir/apps/api/lib/api/controllers/resource_json.ex new file mode 100644 index 000000000..d479974a5 --- /dev/null +++ b/elixir/apps/api/lib/api/controllers/resource_json.ex @@ -0,0 +1,31 @@ +defmodule API.ResourceJSON do + alias API.Pagination + alias Domain.Resources.Resource + + @doc """ + Renders a list of resources. + """ + def index(%{resources: resources, metadata: metadata}) do + %{ + data: Enum.map(resources, &data/1), + metadata: Pagination.metadata(metadata) + } + end + + @doc """ + Renders a single resource. + """ + def show(%{resource: resource}) do + %{data: data(resource)} + end + + defp data(%Resource{} = resource) do + %{ + id: resource.id, + name: resource.name, + address: resource.address, + description: resource.address_description, + type: resource.type + } + end +end diff --git a/elixir/apps/api/lib/api/error_view.ex b/elixir/apps/api/lib/api/error_view.ex index 830cd1ced..30092d5d3 100644 --- a/elixir/apps/api/lib/api/error_view.ex +++ b/elixir/apps/api/lib/api/error_view.ex @@ -1,12 +1,12 @@ defmodule API.ErrorView do def render("500.json", _assigns) do - %{errors: %{detail: "internal_error"}} + %{error: %{reason: "internal_error"}} end # By default, Phoenix returns the status message from # the template name. For example, "404.json" becomes # "Not Found". def render(template, _assigns) do - %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} + %{error: %{reason: Phoenix.Controller.status_message_from_template(template)}} end end diff --git a/elixir/apps/api/lib/api/plugs/auth.ex b/elixir/apps/api/lib/api/plugs/auth.ex index 9fdcb1029..73140e140 100644 --- a/elixir/apps/api/lib/api/plugs/auth.ex +++ b/elixir/apps/api/lib/api/plugs/auth.ex @@ -11,9 +11,16 @@ defmodule API.Plugs.Auth do assign(conn, :subject, subject) else _ -> + # conn + # |> put_resp_content_type("application/json") + # |> send_resp(401, Jason.encode!(%{"error" => "invalid_access_token"})) + # |> halt() + + # TODO: BRIAN - Confirm that this change won't break anything with the clients or gateways conn - |> put_resp_content_type("application/json") - |> send_resp(401, Jason.encode!(%{"error" => "invalid_access_token"})) + |> put_status(401) + |> Phoenix.Controller.put_view(json: API.ErrorJSON) + |> Phoenix.Controller.render(:"401") |> halt() end end diff --git a/elixir/apps/api/lib/api/router.ex b/elixir/apps/api/lib/api/router.ex index 0f5492a2a..46123ea3a 100644 --- a/elixir/apps/api/lib/api/router.ex +++ b/elixir/apps/api/lib/api/router.ex @@ -15,16 +15,53 @@ defmodule API.Router do plug :accepts, ["html", "xml", "json"] end + pipeline :openapi do + plug OpenApiSpex.Plug.PutApiSpec, module: API.ApiSpec + end + + scope "/openapi" do + pipe_through :openapi + + get "/", OpenApiSpex.Plug.RenderSpec, [] + end + + scope "/swaggerui" do + pipe_through :public + + get "/", OpenApiSpex.Plug.SwaggerUI, path: "/openapi" + end + scope "/", API do pipe_through :public get "/healthz", HealthController, :healthz end - scope "/v1", API do + scope "/", API do pipe_through :api - post "/echo", ExampleController, :echo + resources "/resources", ResourceController, except: [:new, :edit] + resources "/policies", PolicyController, except: [:new, :edit] + + resources "/gateway_groups", GatewayGroupController, except: [:new, :edit] do + post "/tokens", GatewayGroupController, :create_token + delete "/tokens", GatewayGroupController, :delete_all_tokens + delete "/tokens/:id", GatewayGroupController, :delete_token + resources "/gateways", GatewayController, except: [:new, :edit, :create, :update] + end + + resources "/actors", ActorController, except: [:new, :edit] do + resources "/identities", IdentityController, except: [:new, :edit, :update] + post "/providers/:provider_id/identities/", IdentityController, :create + end + + resources "/actor_groups", ActorGroupController, except: [:new, :edit] do + get "/memberships", ActorGroupMembershipController, :index + put "/memberships", ActorGroupMembershipController, :update_put + patch "/memberships", ActorGroupMembershipController, :update_patch + end + + resources "/identity_providers", IdentityProviderController, only: [:index, :show, :delete] end scope "/integrations", API.Integrations do diff --git a/elixir/apps/api/lib/api/schemas/actor_group_membership_schema.ex b/elixir/apps/api/lib/api/schemas/actor_group_membership_schema.ex new file mode 100644 index 000000000..b6b313468 --- /dev/null +++ b/elixir/apps/api/lib/api/schemas/actor_group_membership_schema.ex @@ -0,0 +1,170 @@ +defmodule API.Schemas.ActorGroupMembership do + alias OpenApiSpex.Schema + + defmodule Schema do + require OpenApiSpex + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "Actor Group Membership", + description: "Actor Group Membership", + type: :array, + items: %Schema{ + type: :object, + properties: %{ + id: %Schema{type: :string, description: "Actor ID"}, + name: %Schema{type: :string, description: "Actor Name"}, + type: %Schema{type: :string, description: "Actor Type"} + } + }, + example: [ + %{ + "id" => "7cb89288-1fb3-433e-a522-2d087e45988d", + "name" => "John Doe", + "type" => "account_user" + } + ] + }) + end + + defmodule PatchRequest do + require OpenApiSpex + alias OpenApiSpex.Schema + alias API.Schemas.ActorGroupMembership + + OpenApiSpex.schema(%{ + title: "Actor Group Membership Patch Request", + description: "PATCH body for updating Actor Group Memberships", + type: :object, + properties: %{ + memberships: %Schema{ + type: :object, + properties: %{ + add: %Schema{ + type: :array, + description: "Array of Actor IDs", + items: %Schema{type: :string, description: "Actor ID"} + }, + remove: %Schema{ + type: :array, + description: "Array of Actor IDs", + items: %Schema{type: :string, description: "Actor ID"} + } + } + } + }, + required: [:memberships], + example: %{ + "memberships" => %{ + "add" => ["1234-1234"], + "remove" => ["2345-2345"] + } + } + }) + end + + defmodule PutRequest do + require OpenApiSpex + alias Ecto.Adapter.Schema + alias Ecto.Adapter.Schema + alias Ecto.Adapter.Schema + alias OpenApiSpex.Schema + alias API.Schemas.ActorGroupMembership + + OpenApiSpex.schema(%{ + title: "Actor Group Membership Put Request", + description: "PUT body for updating Actor Group Memberships", + type: :object, + properties: %{ + memberships: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + actor_id: %Schema{type: :string, description: "Actor ID"} + } + } + } + }, + required: [:memberships], + example: %{ + "memberships" => [ + %{"actor_id" => "1234-1234"}, + %{"actor_id" => "2345-2345"} + ] + } + }) + end + + defmodule ListResponse do + require OpenApiSpex + alias OpenApiSpex.Schema + alias API.Schemas.ActorGroupMembership + + OpenApiSpex.schema(%{ + title: "Actor Group Membership List Response", + description: "Response schema for Actor Group Memberships", + type: :object, + properties: %{ + data: %Schema{ + description: "Actor Group Membership details", + type: :array, + items: ActorGroupMembership.Schema + }, + metadata: %Schema{description: "Pagination metadata", type: :object} + }, + example: %{ + "data" => [ + %{ + "id" => "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "name" => "John Doe", + "type" => "account_user" + }, + %{ + "id" => "cc9f561a-444d-4083-ab38-0abc6cf2314c", + "name" => "Jane Smith", + "type" => "account_admin_user" + } + ], + "metadata" => %{ + "limit" => 10, + "total" => 100, + "prev_page" => "123123425", + "next_page" => "98776234123" + } + } + }) + end + + defmodule MembershipResponse do + require OpenApiSpex + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "Actor Group Membership Response", + description: "Response schema for Actor Group Membership Updates", + type: :object, + properties: %{ + data: %Schema{ + type: :object, + description: "Actor Group Memberships", + properties: %{ + actor_ids: %Schema{ + description: "Actor IDs", + type: :array, + items: %Schema{type: :string, description: "Actor ID"} + } + } + } + }, + example: %{ + "data" => %{ + "actor_ids" => [ + "4ddfa557-7dfc-484f-894c-2024ec3fe9f7", + "89d22f71-939d-442d-b148-897b730adfb4" + ] + } + } + }) + end +end diff --git a/elixir/apps/api/lib/api/schemas/actor_group_schema.ex b/elixir/apps/api/lib/api/schemas/actor_group_schema.ex new file mode 100644 index 000000000..dbd96ea41 --- /dev/null +++ b/elixir/apps/api/lib/api/schemas/actor_group_schema.ex @@ -0,0 +1,99 @@ +defmodule API.Schemas.ActorGroup do + alias OpenApiSpex.Schema + + defmodule Schema do + require OpenApiSpex + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "Actor Group", + description: "Actor Group", + type: :object, + properties: %{ + id: %Schema{type: :string, description: "Actor Group ID"}, + name: %Schema{type: :string, description: "Actor Group Name"} + }, + required: [:id, :name], + example: %{ + "id" => "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "name" => "Engineering" + } + }) + end + + defmodule Request do + require OpenApiSpex + alias OpenApiSpex.Schema + alias API.Schemas.ActorGroup + + OpenApiSpex.schema(%{ + title: "Actor Group Request", + description: "POST body for creating an Actor Group", + type: :object, + properties: %{ + actor_group: %Schema{anyOf: [ActorGroup.Schema]} + }, + required: [:actor_group], + example: %{ + "actor_group" => %{ + "name" => "Engineering" + } + } + }) + end + + defmodule Response do + require OpenApiSpex + alias OpenApiSpex.Schema + alias API.Schemas.ActorGroup + + OpenApiSpex.schema(%{ + title: "Actor GroupResponse", + description: "Response schema for single Actor Group", + type: :object, + properties: %{ + data: ActorGroup.Schema + }, + example: %{ + "data" => %{ + "id" => "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "name" => "Engineering" + } + } + }) + end + + defmodule ListResponse do + require OpenApiSpex + alias OpenApiSpex.Schema + alias API.Schemas.ActorGroup + + OpenApiSpex.schema(%{ + title: "Actor Group List Response", + description: "Response schema for multiple Actor Groups", + type: :object, + properties: %{ + data: %Schema{description: "Actor Group details", type: :array, items: ActorGroup.Schema}, + metadata: %Schema{description: "Pagination metadata", type: :object} + }, + example: %{ + "data" => [ + %{ + "id" => "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "name" => "Engineering" + }, + %{ + "id" => "4ae929a7-1973-43f2-a1a8-9221b91a4c0e", + "name" => "Finance" + } + ], + "metadata" => %{ + "limit" => 10, + "total" => 100, + "prev_page" => "123123425", + "next_page" => "98776234123" + } + } + }) + end +end diff --git a/elixir/apps/api/lib/api/schemas/actor_schema.ex b/elixir/apps/api/lib/api/schemas/actor_schema.ex new file mode 100644 index 000000000..f3a54d3f1 --- /dev/null +++ b/elixir/apps/api/lib/api/schemas/actor_schema.ex @@ -0,0 +1,109 @@ +defmodule API.Schemas.Actor do + alias OpenApiSpex.Schema + + defmodule Schema do + require OpenApiSpex + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "Actor", + description: "Actor", + type: :object, + properties: %{ + id: %Schema{type: :string, description: "Actor ID"}, + name: %Schema{ + type: :string, + description: "Actor Name", + pattern: ~r/[a-zA-Z][a-zA-Z0-9_]+/ + }, + type: %Schema{type: :string, description: "Actor Type"} + }, + required: [:name, :type], + example: %{ + "id" => "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "name" => "John Doe", + "type" => "account_admin_user" + } + }) + end + + defmodule Request do + require OpenApiSpex + alias OpenApiSpex.Schema + alias API.Schemas.Actor + + OpenApiSpex.schema(%{ + title: "ActorRequest", + description: "POST body for creating an Actor", + type: :object, + properties: %{ + actor: %Schema{anyOf: [Actor.Schema]} + }, + required: [:actor], + example: %{ + "actor" => %{ + "name" => "Joe User", + "type" => "account_admin_user" + } + } + }) + end + + defmodule Response do + require OpenApiSpex + alias OpenApiSpex.Schema + alias API.Schemas.Actor + + OpenApiSpex.schema(%{ + title: "ActorResponse", + description: "Response schema for single Actor", + type: :object, + properties: %{ + data: Actor.Schema + }, + example: %{ + "data" => %{ + "id" => "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "name" => "John Doe", + "type" => "account_admin_user" + } + } + }) + end + + defmodule ListResponse do + require OpenApiSpex + alias OpenApiSpex.Schema + alias API.Schemas.Actor + + OpenApiSpex.schema(%{ + title: "ActorsResponse", + description: "Response schema for multiple Actors", + type: :object, + properties: %{ + data: %Schema{description: "Actors details", type: :array, items: Actor.Schema}, + metadata: %Schema{description: "Pagination metadata", type: :object} + }, + example: %{ + "data" => [ + %{ + "id" => "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "name" => "John Doe", + "type" => "account_admin_user" + }, + %{ + "id" => "84e7f82f-831a-4a9d-8f17-c66c2bb6e205", + "name" => "Jane Smith", + "type" => "account_user" + } + ], + "metadata" => %{ + "limit" => 10, + "total" => 100, + "prev_page" => "123123425", + "next_page" => "98776234123" + } + } + }) + end +end diff --git a/elixir/apps/api/lib/api/schemas/gateway_group_schema.ex b/elixir/apps/api/lib/api/schemas/gateway_group_schema.ex new file mode 100644 index 000000000..53abc0381 --- /dev/null +++ b/elixir/apps/api/lib/api/schemas/gateway_group_schema.ex @@ -0,0 +1,103 @@ +defmodule API.Schemas.GatewayGroup do + alias OpenApiSpex.Schema + + defmodule Schema do + require OpenApiSpex + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "Gateway Group", + description: "Gateway Group", + type: :object, + properties: %{ + id: %Schema{type: :string, description: "Gateway Group ID"}, + name: %Schema{type: :string, description: "Gateway Group Name"} + }, + required: [:id, :name], + example: %{ + "id" => "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "name" => "vpc-us-east" + } + }) + end + + defmodule Request do + require OpenApiSpex + alias OpenApiSpex.Schema + alias API.Schemas.GatewayGroup + + OpenApiSpex.schema(%{ + title: "Gateway GroupRequest", + description: "POST body for creating a Gateway Group", + type: :object, + properties: %{ + gateway_group: %Schema{anyOf: [GatewayGroup.Schema]} + }, + required: [:gateway_group], + example: %{ + "gateway_group" => %{ + "name" => "vpc-us-east" + } + } + }) + end + + defmodule Response do + require OpenApiSpex + alias OpenApiSpex.Schema + alias API.Schemas.GatewayGroup + + OpenApiSpex.schema(%{ + title: "Gateway GroupResponse", + description: "Response schema for single Gateway Group", + type: :object, + properties: %{ + data: GatewayGroup.Schema + }, + example: %{ + "data" => %{ + "id" => "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "name" => "vpc-us-east" + } + } + }) + end + + defmodule ListResponse do + require OpenApiSpex + alias OpenApiSpex.Schema + alias API.Schemas.GatewayGroup + + OpenApiSpex.schema(%{ + title: "Gateway Group List Response", + description: "Response schema for multiple Gateway Groups", + type: :object, + properties: %{ + data: %Schema{ + description: "Gateway Group details", + type: :array, + items: GatewayGroup.Schema + }, + metadata: %Schema{description: "Pagination metadata", type: :object} + }, + example: %{ + "data" => [ + %{ + "id" => "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "name" => "vpc-us-east" + }, + %{ + "id" => "6301d7d2-4938-4123-87de-282c01cca656", + "name" => "vpc-us-west" + } + ], + "metadata" => %{ + "limit" => 10, + "total" => 100, + "prev_page" => "123123425", + "next_page" => "98776234123" + } + } + }) + end +end diff --git a/elixir/apps/api/lib/api/schemas/gateway_group_token_schema.ex b/elixir/apps/api/lib/api/schemas/gateway_group_token_schema.ex new file mode 100644 index 000000000..d736817d1 --- /dev/null +++ b/elixir/apps/api/lib/api/schemas/gateway_group_token_schema.ex @@ -0,0 +1,93 @@ +defmodule API.Schemas.GatewayGroupToken do + alias OpenApiSpex.Schema + + defmodule Schema do + require OpenApiSpex + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "Gateway Group Token", + description: "Gateway Group Token", + type: :object, + properties: %{ + id: %Schema{type: :string, description: "Gateway Group Token ID"}, + token: %Schema{type: :string, description: "Gateway Group Token"} + }, + required: [:id, :token], + example: %{ + "id" => "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "token" => "secret-token-here" + } + }) + end + + defmodule NewToken do + require OpenApiSpex + alias OpenApiSpex.Schema + alias API.Schemas.GatewayGroupToken + + OpenApiSpex.schema(%{ + title: "New Gateway Group Token Response", + description: "Response schema for a new Gateway Group Token", + type: :object, + properties: %{ + data: GatewayGroupToken.Schema + }, + example: %{ + "data" => %{ + "id" => "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "token" => "secret-token-here" + } + } + }) + end + + defmodule DeletedToken do + require OpenApiSpex + alias OpenApiSpex.Schema + alias API.Schemas.GatewayGroupToken + + OpenApiSpex.schema(%{ + title: "Deleted Gateway Group Token Response", + description: "Response schema for a new Gateway Group Token", + type: :object, + properties: %{ + data: GatewayGroupToken.Schema + }, + example: %{ + "data" => %{ + "id" => "42a7f82f-831a-4a9d-8f17-c66c2bb6e205" + } + } + }) + end + + defmodule DeletedTokens do + require OpenApiSpex + alias OpenApiSpex.Schema + alias API.Schemas.GatewayGroupToken + + OpenApiSpex.schema(%{ + title: "Deleted Gateway Group Token List Response", + description: "Response schema for deleted Gateway Group Tokens", + type: :object, + properties: %{ + data: %Schema{ + description: "Deleted Gateway Group Tokens", + type: :array, + items: GatewayGroupToken.Schema + } + }, + example: %{ + "data" => [ + %{ + "id" => "42a7f82f-831a-4a9d-8f17-c66c2bb6e205" + }, + %{ + "id" => "6301d7d2-4938-4123-87de-282c01cca656" + } + ] + } + }) + end +end diff --git a/elixir/apps/api/lib/api/schemas/gateway_schema.ex b/elixir/apps/api/lib/api/schemas/gateway_schema.ex new file mode 100644 index 000000000..20e064f2f --- /dev/null +++ b/elixir/apps/api/lib/api/schemas/gateway_schema.ex @@ -0,0 +1,106 @@ +defmodule API.Schemas.Gateway do + alias OpenApiSpex.Schema + + defmodule Schema do + require OpenApiSpex + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "Gateway", + description: "Gateway", + type: :object, + properties: %{ + id: %Schema{type: :string, description: "Gateway ID"}, + name: %Schema{ + type: :string, + description: "Gateway Name", + pattern: ~r/[a-zA-Z][a-zA-Z0-9_]+/ + }, + ipv4: %Schema{ + type: :string, + description: "IPv4 Address of Gateway" + }, + ipv6: %Schema{ + type: :string, + description: "IPv6 Address of Gateway" + }, + online: %Schema{ + type: :boolean, + description: "Online status of Gateway" + } + }, + required: [:name, :type], + example: %{ + "id" => "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "name" => "vpc-us-east", + "ipv4" => "1.2.3.4", + "ipv6" => "", + "online" => true + } + }) + end + + defmodule Response do + require OpenApiSpex + alias OpenApiSpex.Schema + alias API.Schemas.Gateway + + OpenApiSpex.schema(%{ + title: "Gateway Response", + description: "Response schema for single Gateway", + type: :object, + properties: %{ + data: Gateway.Schema + }, + example: %{ + "data" => %{ + "id" => "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "name" => "vpc-us-east", + "ipv4" => "1.2.3.4", + "ipv6" => "", + "online" => true + } + } + }) + end + + defmodule ListResponse do + require OpenApiSpex + alias OpenApiSpex.Schema + alias API.Schemas.Gateway + + OpenApiSpex.schema(%{ + title: "GatewaysResponse", + description: "Response schema for multiple Gateways", + type: :object, + properties: %{ + data: %Schema{description: "Gateways details", type: :array, items: Gateway.Schema}, + metadata: %Schema{description: "Pagination metadata", type: :object} + }, + example: %{ + "data" => [ + %{ + "id" => "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "name" => "vpc-us-east", + "ipv4" => "1.2.3.4", + "ipv6" => "", + "online" => true + }, + %{ + "id" => "6ecc106b-75c1-48a5-846c-14782180c1ff", + "name" => "vpc-us-west", + "ipv4" => "5.6.7.8", + "ipv6" => "", + "online" => true + } + ], + "metadata" => %{ + "limit" => 10, + "total" => 100, + "prev_page" => "123123425", + "next_page" => "98776234123" + } + } + }) + end +end diff --git a/elixir/apps/api/lib/api/schemas/identity_provider_schema.ex b/elixir/apps/api/lib/api/schemas/identity_provider_schema.ex new file mode 100644 index 000000000..ef5e78b84 --- /dev/null +++ b/elixir/apps/api/lib/api/schemas/identity_provider_schema.ex @@ -0,0 +1,82 @@ +defmodule API.Schemas.IdentityProvider do + alias OpenApiSpex.Schema + + defmodule Schema do + require OpenApiSpex + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "Identity Provider", + description: "Identity Provider", + type: :object, + properties: %{ + id: %Schema{type: :string, description: "Identity Provider ID"}, + name: %Schema{type: :string, description: "Identity Provider name"} + }, + required: [:id, :name], + example: %{ + "id" => "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "name" => "OIDC Provider" + } + }) + end + + defmodule Response do + require OpenApiSpex + alias OpenApiSpex.Schema + alias API.Schemas.IdentityProvider + + OpenApiSpex.schema(%{ + title: "Identity Provider Response", + description: "Response schema for single Identity Provider", + type: :object, + properties: %{ + data: IdentityProvider.Schema + }, + example: %{ + "data" => %{ + "id" => "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "name" => "OIDC Provider" + } + } + }) + end + + defmodule ListResponse do + require OpenApiSpex + alias OpenApiSpex.Schema + alias API.Schemas.IdentityProvider + + OpenApiSpex.schema(%{ + title: "Identity Provider List Response", + description: "Response schema for multiple Identity Providers", + type: :object, + properties: %{ + data: %Schema{ + description: "Identity Provider details", + type: :array, + items: IdentityProvider.Schema + }, + metadata: %Schema{description: "Pagination metadata", type: :object} + }, + example: %{ + "data" => [ + %{ + "id" => "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "name" => "OIDC Provider" + }, + %{ + "id" => "23ca9d03-c904-42c9-bd38-f89a6d57d3a8", + "name" => "Okta" + } + ], + "metadata" => %{ + "limit" => 10, + "total" => 100, + "prev_page" => "123123425", + "next_page" => "98776234123" + } + } + }) + end +end diff --git a/elixir/apps/api/lib/api/schemas/identity_schema.ex b/elixir/apps/api/lib/api/schemas/identity_schema.ex new file mode 100644 index 000000000..4b3d8bad5 --- /dev/null +++ b/elixir/apps/api/lib/api/schemas/identity_schema.ex @@ -0,0 +1,111 @@ +defmodule API.Schemas.Identity do + alias OpenApiSpex.Schema + + defmodule Schema do + require OpenApiSpex + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "Identity", + description: "Identity", + type: :object, + properties: %{ + id: %Schema{type: :string, description: "Identity ID"}, + actor_id: %Schema{type: :string, description: "Actor ID"}, + provider_id: %Schema{type: :string, description: "Identity Provider ID"}, + provider_identifier: %Schema{type: :string, description: "Identifier from Provider"} + }, + required: [:id, :actor_id, :provider_id, :provider_identifier], + example: %{ + "id" => "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "actor_id" => "cdfa97e6-cca1-41db-8fc7-864daedb46df", + "provider_id" => "989f9e96-e348-47ec-ba85-869fcd7adb19", + "provider_identifier" => "foo@bar.com" + } + }) + end + + defmodule Request do + require OpenApiSpex + alias OpenApiSpex.Schema + alias API.Schemas.Identity + + OpenApiSpex.schema(%{ + title: "IdentityRequest", + description: "POST body for creating a Identity", + type: :object, + properties: %{ + identity: %Schema{anyOf: [Identity.Schema]} + }, + required: [:identity], + example: %{ + "identity" => %{ + "actor_id" => "cdfa97e6-cca1-41db-8fc7-864daedb46df", + "provider_id" => "989f9e96-e348-47ec-ba85-869fcd7adb19", + "provider_identifier" => "foo@bar.com" + } + } + }) + end + + defmodule Response do + require OpenApiSpex + alias OpenApiSpex.Schema + alias API.Schemas.Identity + + OpenApiSpex.schema(%{ + title: "IdentityResponse", + description: "Response schema for single Identity", + type: :object, + properties: %{ + data: Identity.Schema + }, + example: %{ + "data" => %{ + "id" => "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "actor_id" => "cdfa97e6-cca1-41db-8fc7-864daedb46df", + "provider_id" => "989f9e96-e348-47ec-ba85-869fcd7adb19", + "provider_identifier" => "foo@bar.com" + } + } + }) + end + + defmodule ListResponse do + require OpenApiSpex + alias OpenApiSpex.Schema + alias API.Schemas.Identity + + OpenApiSpex.schema(%{ + title: "Identity List Response", + description: "Response schema for multiple Identities", + type: :object, + properties: %{ + data: %Schema{description: "Identity details", type: :array, items: Identity.Schema}, + metadata: %Schema{description: "Pagination metadata", type: :object} + }, + example: %{ + "data" => [ + %{ + "id" => "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "actor_id" => "8f44a02b-b8eb-406f-8202-4274bf60ebd0", + "provider_id" => "6472d898-5b98-4c3b-b4b9-d3158c1891be", + "provider_identifier" => "foo@bar.com" + }, + %{ + "id" => "8a70eb96-e74b-4cdc-91b8-48c05ef74d4c", + "actor_id" => "38c92cda-1ddb-45b3-9d1a-7efc375e00c1", + "provider_id" => "04f13eed-4845-47c3-833e-fdd869fab96f", + "provider_identifier" => "baz@bar.com" + } + ], + "metadata" => %{ + "limit" => 10, + "total" => 100, + "prev_page" => "123123425", + "next_page" => "98776234123" + } + } + }) + end +end diff --git a/elixir/apps/api/lib/api/schemas/policy_schema.ex b/elixir/apps/api/lib/api/schemas/policy_schema.ex new file mode 100644 index 000000000..80beda2c1 --- /dev/null +++ b/elixir/apps/api/lib/api/schemas/policy_schema.ex @@ -0,0 +1,111 @@ +defmodule API.Schemas.Policy do + alias OpenApiSpex.Schema + + defmodule Schema do + require OpenApiSpex + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "Policy", + description: "Policy", + type: :object, + properties: %{ + id: %Schema{type: :string, description: "Policy ID"}, + actor_group_id: %Schema{type: :string, description: "Actor Group ID"}, + resource_id: %Schema{type: :string, description: "Resource ID"}, + description: %Schema{type: :string, description: "Policy Description"} + }, + required: [:name, :type], + example: %{ + "id" => "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "actor_group_id" => "88eae9ce-9179-48c6-8430-770e38dd4775", + "resource_id" => "a9f60587-793c-46ae-8525-597f43ab2fb1", + "description" => "Policy to allow something" + } + }) + end + + defmodule Request do + require OpenApiSpex + alias OpenApiSpex.Schema + alias API.Schemas.Policy + + OpenApiSpex.schema(%{ + title: "Policy Request", + description: "POST body for creating a Policy", + type: :object, + properties: %{ + policy: %Schema{anyOf: [Policy.Schema]} + }, + required: [:policy], + example: %{ + "policy" => %{ + "resource_id" => "a9f60587-793c-46ae-8525-597f43ab2fb1", + "actor_group_id" => "88eae9ce-9179-48c6-8430-770e38dd4775", + "description" => "Policy to allow something" + } + } + }) + end + + defmodule Response do + require OpenApiSpex + alias OpenApiSpex.Schema + alias API.Schemas.Policy + + OpenApiSpex.schema(%{ + title: "Policy Response", + description: "Response schema for single Policy", + type: :object, + properties: %{ + data: Policy.Schema + }, + example: %{ + "data" => %{ + "id" => "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "resource_id" => "a9f60587-793c-46ae-8525-597f43ab2fb1", + "actor_group_id" => "88eae9ce-9179-48c6-8430-770e38dd4775", + "description" => "Policy to allow something" + } + } + }) + end + + defmodule ListResponse do + require OpenApiSpex + alias OpenApiSpex.Schema + alias API.Schemas.Policy + + OpenApiSpex.schema(%{ + title: "Policy List Response", + description: "Response schema for multiple Policies", + type: :object, + properties: %{ + data: %Schema{description: "Policy details", type: :array, items: Policy.Schema}, + metadata: %Schema{description: "Pagination metadata", type: :object} + }, + example: %{ + "data" => [ + %{ + "id" => "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "resource_id" => "a9f60587-793c-46ae-8525-597f43ab2fb1", + "actor_group_id" => "88eae9ce-9179-48c6-8430-770e38dd4775", + "description" => "Policy to allow something" + }, + %{ + "id" => "6301d7d2-4938-4123-87de-282c01cca656", + "resource_id" => "9876bd25-0f6c-48fb-a9fd-196ba9be86e5", + "actor_group_id" => "343385a2-5437-4c66-8744-1332421ff736", + "description" => "Policy to allow something else" + } + ], + "metadata" => %{ + "limit" => 10, + "total" => 100, + "prev_page" => "123123425", + "next_page" => "98776234123" + } + } + }) + end +end diff --git a/elixir/apps/api/lib/api/schemas/resource_schema.ex b/elixir/apps/api/lib/api/schemas/resource_schema.ex new file mode 100644 index 000000000..32951e719 --- /dev/null +++ b/elixir/apps/api/lib/api/schemas/resource_schema.ex @@ -0,0 +1,118 @@ +defmodule API.Schemas.Resource do + alias OpenApiSpex.Schema + + defmodule Schema do + require OpenApiSpex + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "Resource", + description: "Resource", + type: :object, + properties: %{ + 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"}, + type: %Schema{type: :string, description: "Resource type"} + }, + required: [:name, :type], + example: %{ + "id" => "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "name" => "Prod DB", + "address" => "10.0.0.10", + "description" => "Production Database", + "type" => "ip" + } + }) + end + + defmodule Request do + require OpenApiSpex + alias OpenApiSpex.Schema + alias API.Schemas.Resource + + OpenApiSpex.schema(%{ + title: "ResourceRequest", + description: "POST body for creating a Resource", + type: :object, + properties: %{ + resource: %Schema{anyOf: [Resource.Schema]} + }, + required: [:resource], + example: %{ + "resource" => %{ + "id" => "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "name" => "Prod DB", + "address" => "10.0.0.10", + "description" => "Production Database", + "type" => "ip" + } + } + }) + end + + defmodule Response do + require OpenApiSpex + alias OpenApiSpex.Schema + alias API.Schemas.Resource + + OpenApiSpex.schema(%{ + title: "ResourceResponse", + description: "Response schema for single Resource", + type: :object, + properties: %{ + data: Resource.Schema + }, + example: %{ + "data" => %{ + "id" => "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "name" => "Prod DB", + "address" => "10.0.0.10", + "description" => "Production Database", + "type" => "ip" + } + } + }) + end + + defmodule ListResponse do + require OpenApiSpex + alias OpenApiSpex.Schema + alias API.Schemas.Resource + + OpenApiSpex.schema(%{ + title: "Gateway List Response", + description: "Response schema for multiple Gateways", + type: :object, + properties: %{ + data: %Schema{description: "Resource details", type: :array, items: Resource.Schema}, + metadata: %Schema{description: "Pagination metadata", type: :object} + }, + example: %{ + "data" => [ + %{ + "id" => "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "name" => "Prod DB", + "address" => "10.0.0.10", + "description" => "Production Database", + "type" => "ip" + }, + %{ + "id" => "3b9451c9-5616-48f8-827f-009ace22d015", + "name" => "Admin Dashboard", + "address" => "10.0.0.20", + "description" => "Production Admin Dashboard", + "type" => "ip" + } + ], + "metadata" => %{ + "limit" => 10, + "total" => 100, + "prev_page" => "123123425", + "next_page" => "98776234123" + } + } + }) + end +end diff --git a/elixir/apps/api/mix.exs b/elixir/apps/api/mix.exs index 7025cf169..f49b68c34 100644 --- a/elixir/apps/api/mix.exs +++ b/elixir/apps/api/mix.exs @@ -58,6 +58,8 @@ defmodule API.MixProject do # Other deps {:jason, "~> 1.2"}, {:remote_ip, "~> 1.1"}, + {:open_api_spex, "~> 3.18"}, + {:ymlr, "~> 2.0"}, # Test deps {:credo, "~> 1.5", only: [:dev, :test], runtime: false}, diff --git a/elixir/apps/api/test/api/controllers/actor_controller_test.exs b/elixir/apps/api/test/api/controllers/actor_controller_test.exs new file mode 100644 index 000000000..90dad7393 --- /dev/null +++ b/elixir/apps/api/test/api/controllers/actor_controller_test.exs @@ -0,0 +1,236 @@ +defmodule API.ActorControllerTest do + use API.ConnCase, async: true + alias Domain.Actors.Actor + + setup do + account = Fixtures.Accounts.create_account() + actor = Fixtures.Actors.create_actor(type: :api_client, account: account) + + %{ + account: account, + actor: actor + } + end + + describe "index/2" do + test "returns error when not authorized", %{conn: conn} do + conn = get(conn, "/actors") + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "lists all actors", %{conn: conn, account: account, actor: actor} do + actors = for _ <- 1..3, do: Fixtures.Actors.create_actor(%{account: account}) + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> get("/actors") + + assert %{ + "data" => data, + "metadata" => %{ + "count" => count, + "limit" => limit, + "next_page" => next_page, + "prev_page" => prev_page + } + } = json_response(conn, 200) + + assert count == 4 + assert limit == 50 + assert is_nil(next_page) + assert is_nil(prev_page) + + data_ids = Enum.map(data, & &1["id"]) + actor_ids = Enum.map(actors, & &1.id) ++ [actor.id] + + assert equal_ids?(data_ids, actor_ids) + end + + test "lists actors with limit", %{conn: conn, account: account, actor: actor} do + actors = for _ <- 1..3, do: Fixtures.Actors.create_actor(%{account: account}) + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> get("/actors", limit: "2") + + assert %{ + "data" => data, + "metadata" => %{ + "count" => count, + "limit" => limit, + "next_page" => next_page, + "prev_page" => prev_page + } + } = json_response(conn, 200) + + assert limit == 2 + assert count == 4 + refute is_nil(next_page) + assert is_nil(prev_page) + + data_ids = Enum.map(data, & &1["id"]) |> MapSet.new() + actor_ids = (Enum.map(actors, & &1.id) ++ [actor.id]) |> MapSet.new() + + assert MapSet.subset?(data_ids, actor_ids) + end + end + + describe "show/2" do + test "returns error when not authorized", %{conn: conn, account: account} do + actor = Fixtures.Actors.create_actor(%{account: account}) + conn = get(conn, "/actors/#{actor.id}") + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "returns a single actor", %{conn: conn, account: account, actor: api_actor} do + actor = Fixtures.Actors.create_actor(%{account: account}) + + conn = + conn + |> authorize_conn(api_actor) + |> put_req_header("content-type", "application/json") + |> get("/actors/#{actor.id}") + + assert json_response(conn, 200) == %{ + "data" => %{ + "id" => actor.id, + "name" => actor.name, + "type" => Atom.to_string(actor.type) + } + } + end + end + + describe "create/2" do + test "returns error when not authorized", %{conn: conn} do + conn = post(conn, "/actors", %{}) + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "returns error on empty params/body", %{conn: conn, actor: api_actor} do + conn = + conn + |> authorize_conn(api_actor) + |> put_req_header("content-type", "application/json") + |> post("/actors") + + assert resp = json_response(conn, 400) + assert resp == %{"error" => %{"reason" => "Bad Request"}} + end + + test "returns error on invalid attrs", %{conn: conn, actor: api_actor} do + attrs = %{} + + conn = + conn + |> authorize_conn(api_actor) + |> put_req_header("content-type", "application/json") + |> post("/actors", actor: attrs) + + assert resp = json_response(conn, 422) + + assert resp == + %{ + "error" => %{ + "reason" => "Unprocessable Entity", + "validation_errors" => %{ + "name" => ["can't be blank"], + "type" => ["can't be blank"] + } + } + } + end + + test "creates a actor with valid attrs", %{conn: conn, actor: api_actor} do + # TODO: At the moment, API clients aren't allowed to create admin users + attrs = %{ + "name" => "Test User", + "type" => "account_user" + } + + conn = + conn + |> authorize_conn(api_actor) + |> put_req_header("content-type", "application/json") + |> post("/actors", actor: attrs) + + assert resp = json_response(conn, 201) + + assert resp["data"]["name"] == attrs["name"] + assert resp["data"]["type"] == attrs["type"] + end + end + + describe "update/2" do + test "returns error when not authorized", %{conn: conn, account: account} do + actor = Fixtures.Actors.create_actor(%{account: account}) + conn = put(conn, "/actors/#{actor.id}", %{}) + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "returns error on empty params/body", %{conn: conn, account: account, actor: api_actor} do + actor = Fixtures.Actors.create_actor(%{account: account, type: :account_admin_user}) + + conn = + conn + |> authorize_conn(api_actor) + |> put_req_header("content-type", "application/json") + |> put("/actors/#{actor.id}") + + assert resp = json_response(conn, 400) + assert resp == %{"error" => %{"reason" => "Bad Request"}} + end + + test "updates an actor", %{conn: conn, account: account, actor: api_actor} do + actor = Fixtures.Actors.create_actor(%{account: account, type: :account_admin_user}) + _other_admin = Fixtures.Actors.create_actor(%{account: account, type: :account_admin_user}) + + attrs = %{"type" => "account_user"} + + conn = + conn + |> authorize_conn(api_actor) + |> put_req_header("content-type", "application/json") + |> put("/actors/#{actor.id}", actor: attrs) + + assert resp = json_response(conn, 200) + + assert resp["data"]["id"] == actor.id + assert resp["data"]["name"] == actor.name + assert resp["data"]["type"] == attrs["type"] + end + end + + describe "delete/2" do + test "returns error when not authorized", %{conn: conn, account: account} do + actor = Fixtures.Actors.create_actor(%{account: account}) + conn = delete(conn, "/actors/#{actor.id}") + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "deletes a resource", %{conn: conn, account: account, actor: api_actor} do + actor = Fixtures.Actors.create_actor(%{account: account}) + + conn = + conn + |> authorize_conn(api_actor) + |> put_req_header("content-type", "application/json") + |> delete("/actors/#{actor.id}") + + assert json_response(conn, 200) == %{ + "data" => %{ + "id" => actor.id, + "name" => actor.name, + "type" => Atom.to_string(actor.type) + } + } + + assert actor = Repo.get(Actor, actor.id) + assert actor.deleted_at + end + end +end diff --git a/elixir/apps/api/test/api/controllers/actor_group_controller_test.exs b/elixir/apps/api/test/api/controllers/actor_group_controller_test.exs new file mode 100644 index 000000000..cc057afee --- /dev/null +++ b/elixir/apps/api/test/api/controllers/actor_group_controller_test.exs @@ -0,0 +1,226 @@ +defmodule API.ActorGroupControllerTest do + use API.ConnCase, async: true + alias Domain.Actors.Group + + setup do + account = Fixtures.Accounts.create_account() + actor = Fixtures.Actors.create_actor(type: :api_client, account: account) + + %{ + account: account, + actor: actor + } + end + + describe "index/2" do + test "returns error when not authorized", %{conn: conn} do + conn = get(conn, "/actor_groups") + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "lists all actor groups", %{conn: conn, account: account, actor: actor} do + actor_groups = for _ <- 1..3, do: Fixtures.Actors.create_group(%{account: account}) + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> get("/actor_groups") + + assert %{ + "data" => data, + "metadata" => %{ + "count" => count, + "limit" => limit, + "next_page" => next_page, + "prev_page" => prev_page + } + } = json_response(conn, 200) + + assert count == 3 + assert limit == 50 + assert is_nil(next_page) + assert is_nil(prev_page) + + data_ids = Enum.map(data, & &1["id"]) + actor_group_ids = Enum.map(actor_groups, & &1.id) + + assert equal_ids?(data_ids, actor_group_ids) + end + + test "lists actor groups with limit", %{conn: conn, account: account, actor: actor} do + actor_groups = for _ <- 1..3, do: Fixtures.Actors.create_group(%{account: account}) + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> get("/actor_groups", limit: "2") + + assert %{ + "data" => data, + "metadata" => %{ + "count" => count, + "limit" => limit, + "next_page" => next_page, + "prev_page" => prev_page + } + } = json_response(conn, 200) + + assert limit == 2 + assert count == 3 + refute is_nil(next_page) + assert is_nil(prev_page) + + data_ids = Enum.map(data, & &1["id"]) |> MapSet.new() + actor_group_ids = Enum.map(actor_groups, & &1.id) |> MapSet.new() + + assert MapSet.subset?(data_ids, actor_group_ids) + end + end + + describe "show/2" do + test "returns error when not authorized", %{conn: conn, account: account} do + actor_group = Fixtures.Actors.create_group(%{account: account}) + conn = get(conn, "/actor_groups/#{actor_group.id}") + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "returns a single actor group", %{conn: conn, account: account, actor: actor} do + actor_group = Fixtures.Actors.create_group(%{account: account}) + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> get("/actor_groups/#{actor_group.id}") + + assert json_response(conn, 200) == %{ + "data" => %{ + "id" => actor_group.id, + "name" => actor_group.name + } + } + end + end + + describe "create/2" do + test "returns error when not authorized", %{conn: conn} do + conn = post(conn, "/actor_groups", %{}) + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "returns error on empty params/body", %{conn: conn, actor: actor} do + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> post("/actor_groups") + + assert resp = json_response(conn, 400) + assert resp == %{"error" => %{"reason" => "Bad Request"}} + end + + test "returns error on invalid attrs", %{conn: conn, actor: actor} do + attrs = %{} + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> post("/actor_groups", actor_group: attrs) + + assert resp = json_response(conn, 422) + + assert resp == + %{ + "error" => %{ + "reason" => "Unprocessable Entity", + "validation_errors" => %{"name" => ["can't be blank"]} + } + } + end + + test "creates an actor group with valid attrs", %{conn: conn, actor: actor} do + attrs = %{ + "name" => "Test Actor Group" + } + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> post("/actor_groups", actor_group: attrs) + + assert resp = json_response(conn, 201) + + assert resp["data"]["name"] == attrs["name"] + end + end + + describe "update/2" do + test "returns error when not authorized", %{conn: conn, account: account} do + actor_group = Fixtures.Actors.create_group(%{account: account}) + conn = put(conn, "/actor_groups/#{actor_group.id}", %{}) + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "returns error on empty params/body", %{conn: conn, account: account, actor: actor} do + actor_group = Fixtures.Actors.create_group(%{account: account}) + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> put("/actor_groups/#{actor_group.id}") + + assert resp = json_response(conn, 400) + assert resp == %{"error" => %{"reason" => "Bad Request"}} + end + + test "updates an actor group", %{conn: conn, account: account, actor: actor} do + actor_group = Fixtures.Actors.create_group(%{account: account}) + + attrs = %{"name" => "Updated Actor Group"} + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> put("/actor_groups/#{actor_group.id}", actor_group: attrs) + + assert resp = json_response(conn, 200) + + assert resp["data"]["id"] == actor_group.id + assert resp["data"]["name"] == attrs["name"] + end + end + + describe "delete/2" do + test "returns error when not authorized", %{conn: conn, account: account} do + actor_group = Fixtures.Actors.create_group(%{account: account}) + conn = delete(conn, "/actor_groups/#{actor_group.id}") + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "deletes an actor group", %{conn: conn, account: account, actor: actor} do + actor_group = Fixtures.Actors.create_group(%{account: account}) + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> delete("/actor_groups/#{actor_group.id}") + + assert json_response(conn, 200) == %{ + "data" => %{ + "id" => actor_group.id, + "name" => actor_group.name + } + } + + assert actor_group = Repo.get(Group, actor_group.id) + assert actor_group.deleted_at + end + end +end diff --git a/elixir/apps/api/test/api/controllers/actor_group_membership_controller_test.exs b/elixir/apps/api/test/api/controllers/actor_group_membership_controller_test.exs new file mode 100644 index 000000000..b8b997561 --- /dev/null +++ b/elixir/apps/api/test/api/controllers/actor_group_membership_controller_test.exs @@ -0,0 +1,253 @@ +defmodule API.ActorGroupMembershipControllerTest do + use API.ConnCase, async: true + + setup do + account = Fixtures.Accounts.create_account() + actor = Fixtures.Actors.create_actor(type: :api_client, account: account) + + %{ + account: account, + actor: actor + } + end + + describe "index/2" do + test "returns error when not authorized", %{conn: conn, account: account} do + actor_group = Fixtures.Actors.create_group(%{account: account}) + conn = get(conn, "/actor_groups/#{actor_group.id}/memberships") + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "lists all memberships", %{conn: conn, account: account, actor: api_actor} do + actor_group = Fixtures.Actors.create_group(%{account: account}) + + memberships = + for _ <- 1..3, + do: Fixtures.Actors.create_membership(%{account: account, group: actor_group}) + + conn = + conn + |> authorize_conn(api_actor) + |> put_req_header("content-type", "application/json") + |> get("/actor_groups/#{actor_group.id}/memberships") + + assert %{ + "data" => data, + "metadata" => %{ + "count" => count, + "limit" => limit, + "next_page" => next_page, + "prev_page" => prev_page + } + } = json_response(conn, 200) + + assert count == 3 + assert limit == 50 + assert is_nil(next_page) + assert is_nil(prev_page) + + data_ids = Enum.map(data, & &1["id"]) + membership_ids = Enum.map(memberships, & &1.actor_id) + + assert equal_ids?(membership_ids, data_ids) + end + + test "lists identity providers with limit", %{conn: conn, account: account, actor: actor} do + actor_group = Fixtures.Actors.create_group(%{account: account}) + + memberships = + for _ <- 1..3, + do: Fixtures.Actors.create_membership(%{account: account, group: actor_group}) + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> get("/actor_groups/#{actor_group.id}/memberships", limit: "2") + + assert %{ + "data" => data, + "metadata" => %{ + "count" => count, + "limit" => limit, + "next_page" => next_page, + "prev_page" => prev_page + } + } = json_response(conn, 200) + + assert limit == 2 + assert count == 3 + refute is_nil(next_page) + assert is_nil(prev_page) + + data_ids = Enum.map(data, & &1["id"]) |> MapSet.new() + assert MapSet.size(data_ids) == 2 + + membership_ids = + Enum.map(memberships, & &1.actor_id) |> MapSet.new() + + assert MapSet.subset?(data_ids, membership_ids) + end + end + + describe "update_patch/2" do + test "adds actor to group", %{conn: conn, account: account, actor: api_actor} do + actor_group = Fixtures.Actors.create_group(%{account: account}) + actor = Fixtures.Actors.create_actor(%{account: account}) + attrs = %{"add" => [actor.id]} + + conn = + conn + |> authorize_conn(api_actor) + |> put_req_header("content-type", "application/json") + |> patch("/actor_groups/#{actor_group.id}/memberships", memberships: attrs) + + assert %{"data" => data} = json_response(conn, 200) + assert data == %{"actor_ids" => [actor.id]} + end + + test "returns error on empty params/body", %{conn: conn, account: account, actor: api_actor} do + actor_group = Fixtures.Actors.create_group(%{account: account}) + + conn = + conn + |> authorize_conn(api_actor) + |> put_req_header("content-type", "application/json") + |> patch("/actor_groups/#{actor_group.id}/memberships") + + assert resp = json_response(conn, 400) + assert resp == %{"error" => %{"reason" => "Bad Request"}} + end + + test "removes actor from group", %{conn: conn, account: account, actor: api_actor} do + actor_group = Fixtures.Actors.create_group(%{account: account}) + actor1 = Fixtures.Actors.create_actor(%{account: account}) + actor2 = Fixtures.Actors.create_actor(%{account: account}) + Fixtures.Actors.create_membership(%{account: account, actor: actor1, group: actor_group}) + Fixtures.Actors.create_membership(%{account: account, actor: actor2, group: actor_group}) + + attrs = %{"remove" => [actor2.id]} + + conn = + conn + |> authorize_conn(api_actor) + |> put_req_header("content-type", "application/json") + |> patch("/actor_groups/#{actor_group.id}/memberships", memberships: attrs) + + assert %{"data" => data} = json_response(conn, 200) + assert data == %{"actor_ids" => [actor1.id]} + end + + test "adds and removes actors from group", %{conn: conn, account: account, actor: api_actor} do + actor_group = Fixtures.Actors.create_group(%{account: account}) + actor1 = Fixtures.Actors.create_actor(%{account: account}) + actor2 = Fixtures.Actors.create_actor(%{account: account}) + actor3 = Fixtures.Actors.create_actor(%{account: account}) + Fixtures.Actors.create_membership(%{account: account, actor: actor1, group: actor_group}) + Fixtures.Actors.create_membership(%{account: account, actor: actor2, group: actor_group}) + + attrs = %{"add" => [actor3.id], "remove" => [actor2.id]} + + conn = + conn + |> authorize_conn(api_actor) + |> put_req_header("content-type", "application/json") + |> patch("/actor_groups/#{actor_group.id}/memberships", memberships: attrs) + + assert %{"data" => data} = json_response(conn, 200) + assert Enum.sort(data["actor_ids"]) == Enum.sort([actor1.id, actor3.id]) + end + + test "group remains the same on empty params", %{ + conn: conn, + account: account, + actor: api_actor + } do + actor_group = Fixtures.Actors.create_group(%{account: account}) + actor1 = Fixtures.Actors.create_actor(%{account: account}) + actor2 = Fixtures.Actors.create_actor(%{account: account}) + Fixtures.Actors.create_membership(%{account: account, actor: actor1, group: actor_group}) + Fixtures.Actors.create_membership(%{account: account, actor: actor2, group: actor_group}) + + attrs = %{} + + conn = + conn + |> authorize_conn(api_actor) + |> put_req_header("content-type", "application/json") + |> patch("/actor_groups/#{actor_group.id}/memberships", memberships: attrs) + + assert %{"data" => data} = json_response(conn, 200) + assert Enum.sort(data["actor_ids"]) == Enum.sort([actor1.id, actor2.id]) + end + end + + describe "update_put/2" do + test "adds actor to group", %{conn: conn, account: account, actor: api_actor} do + actor_group = Fixtures.Actors.create_group(%{account: account}) + actor = Fixtures.Actors.create_actor(%{account: account}) + attrs = [%{"actor_id" => actor.id}] + + conn = + conn + |> authorize_conn(api_actor) + |> put_req_header("content-type", "application/json") + |> put("/actor_groups/#{actor_group.id}/memberships", memberships: attrs) + + assert %{"data" => data} = json_response(conn, 200) + assert data == %{"actor_ids" => [actor.id]} + end + + test "returns error on empty params/body", %{conn: conn, account: account, actor: api_actor} do + actor_group = Fixtures.Actors.create_group(%{account: account}) + + conn = + conn + |> authorize_conn(api_actor) + |> put_req_header("content-type", "application/json") + |> put("/actor_groups/#{actor_group.id}/memberships") + + assert resp = json_response(conn, 400) + assert resp == %{"error" => %{"reason" => "Bad Request"}} + end + + test "removes actor from group", %{conn: conn, account: account, actor: api_actor} do + actor_group = Fixtures.Actors.create_group(%{account: account}) + actor1 = Fixtures.Actors.create_actor(%{account: account}) + actor2 = Fixtures.Actors.create_actor(%{account: account}) + Fixtures.Actors.create_membership(%{account: account, actor: actor1, group: actor_group}) + Fixtures.Actors.create_membership(%{account: account, actor: actor2, group: actor_group}) + + attrs = [%{"actor_id" => actor1.id}] + + conn = + conn + |> authorize_conn(api_actor) + |> put_req_header("content-type", "application/json") + |> put("/actor_groups/#{actor_group.id}/memberships", memberships: attrs) + + assert %{"data" => data} = json_response(conn, 200) + assert data == %{"actor_ids" => [actor1.id]} + end + + test "adds and removes actors from group", %{conn: conn, account: account, actor: api_actor} do + actor_group = Fixtures.Actors.create_group(%{account: account}) + actor1 = Fixtures.Actors.create_actor(%{account: account}) + actor2 = Fixtures.Actors.create_actor(%{account: account}) + actor3 = Fixtures.Actors.create_actor(%{account: account}) + Fixtures.Actors.create_membership(%{account: account, actor: actor1, group: actor_group}) + Fixtures.Actors.create_membership(%{account: account, actor: actor2, group: actor_group}) + + attrs = [%{"actor_id" => actor1.id}, %{"actor_id" => actor3.id}] + + conn = + conn + |> authorize_conn(api_actor) + |> put_req_header("content-type", "application/json") + |> put("/actor_groups/#{actor_group.id}/memberships", memberships: attrs) + + assert %{"data" => data} = json_response(conn, 200) + assert Enum.sort(data["actor_ids"]) == Enum.sort([actor1.id, actor3.id]) + end + end +end diff --git a/elixir/apps/api/test/api/controllers/example_controller_test.exs b/elixir/apps/api/test/api/controllers/example_controller_test.exs deleted file mode 100644 index 3fddcfcd8..000000000 --- a/elixir/apps/api/test/api/controllers/example_controller_test.exs +++ /dev/null @@ -1,22 +0,0 @@ -defmodule API.ExampleControllerTest do - use API.ConnCase, async: true - - describe "echo/2" do - test "returns error when not authorized", %{conn: conn} do - conn = post(conn, "/v1/echo", %{"message" => "Hello, world!"}) - assert json_response(conn, 401) == %{"error" => "invalid_access_token"} - end - - test "returns 200 OK with the request body", %{conn: conn} do - actor = Fixtures.Actors.create_actor(type: :api_client) - - conn = - conn - |> authorize_conn(actor) - |> put_req_header("content-type", "application/json") - |> post("/v1/echo", Jason.encode!(%{"message" => "Hello, world!"})) - - assert json_response(conn, 200) == %{"message" => "Hello, world!"} - end - end -end diff --git a/elixir/apps/api/test/api/controllers/gateway_controller_test.exs b/elixir/apps/api/test/api/controllers/gateway_controller_test.exs new file mode 100644 index 000000000..d90ad9b53 --- /dev/null +++ b/elixir/apps/api/test/api/controllers/gateway_controller_test.exs @@ -0,0 +1,174 @@ +defmodule API.GatewayControllerTest do + use API.ConnCase, async: true + alias Domain.Gateways.Gateway + + setup do + account = Fixtures.Accounts.create_account() + actor = Fixtures.Actors.create_actor(type: :api_client, account: account) + gateway_group = Fixtures.Gateways.create_group(%{account: account}) + + %{ + account: account, + actor: actor, + gateway_group: gateway_group + } + end + + describe "index/2" do + test "returns error when not authorized", %{conn: conn, gateway_group: gateway_group} do + conn = get(conn, "/gateway_groups/#{gateway_group.id}/gateways") + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "lists all gateways for a gateway group", %{ + conn: conn, + account: account, + actor: actor, + gateway_group: gateway_group + } do + gateways = + for _ <- 1..3, + do: Fixtures.Gateways.create_gateway(%{account: account, group: gateway_group}) + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> get("/gateway_groups/#{gateway_group.id}/gateways") + + assert %{ + "data" => data, + "metadata" => %{ + "count" => count, + "limit" => limit, + "next_page" => next_page, + "prev_page" => prev_page + } + } = json_response(conn, 200) + + assert count == 3 + assert limit == 50 + assert is_nil(next_page) + assert is_nil(prev_page) + + data_ids = Enum.map(data, & &1["id"]) + gateway_ids = Enum.map(gateways, & &1.id) + + assert equal_ids?(data_ids, gateway_ids) + end + + test "lists gateways with limit", %{ + conn: conn, + account: account, + actor: actor, + gateway_group: gateway_group + } do + gateways = + for _ <- 1..3, + do: Fixtures.Gateways.create_gateway(%{account: account, group: gateway_group}) + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> get("/gateway_groups/#{gateway_group.id}/gateways", limit: "2") + + assert %{ + "data" => data, + "metadata" => %{ + "count" => count, + "limit" => limit, + "next_page" => next_page, + "prev_page" => prev_page + } + } = json_response(conn, 200) + + assert limit == 2 + assert count == 3 + refute is_nil(next_page) + assert is_nil(prev_page) + + data_ids = Enum.map(data, & &1["id"]) |> MapSet.new() + gateway_ids = Enum.map(gateways, & &1.id) |> MapSet.new() + + assert MapSet.subset?(data_ids, gateway_ids) + end + end + + describe "show/2" do + test "returns error when not authorized", %{ + conn: conn, + account: account, + gateway_group: gateway_group + } do + gateway = Fixtures.Gateways.create_gateway(%{account: account, group: gateway_group}) + conn = get(conn, "/gateway_groups/#{gateway_group.id}/gateways/#{gateway.id}") + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "returns a single gateway", %{ + conn: conn, + account: account, + actor: actor, + gateway_group: gateway_group + } do + gateway = Fixtures.Gateways.create_gateway(%{account: account, group: gateway_group}) + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> get("/gateway_groups/#{gateway_group.id}/gateways/#{gateway.id}") + + assert json_response(conn, 200) == %{ + "data" => %{ + "id" => gateway.id, + "name" => gateway.name, + "ipv4" => Domain.Types.IP.to_string(gateway.ipv4), + "ipv6" => Domain.Types.IP.to_string(gateway.ipv6), + "online" => false + } + } + end + end + + describe "delete/2" do + test "returns error when not authorized", %{ + conn: conn, + account: account, + gateway_group: gateway_group + } do + gateway = Fixtures.Gateways.create_gateway(%{account: account, group: gateway_group}) + conn = delete(conn, "/gateway_groups/#{gateway_group.id}/gateways/#{gateway.id}") + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "deletes a gateway", %{ + conn: conn, + account: account, + actor: actor, + gateway_group: gateway_group + } do + gateway = Fixtures.Gateways.create_gateway(%{account: account, group: gateway_group}) + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> delete("/gateway_groups/#{gateway_group.id}/gateways/#{gateway.id}") + + assert json_response(conn, 200) == %{ + "data" => %{ + "id" => gateway.id, + "name" => gateway.name, + "ipv4" => Domain.Types.IP.to_string(gateway.ipv4), + "ipv6" => Domain.Types.IP.to_string(gateway.ipv6), + "online" => false + } + } + + assert gateway = Repo.get(Gateway, gateway.id) + assert gateway.deleted_at + end + end +end diff --git a/elixir/apps/api/test/api/controllers/gateway_group_controller_test.exs b/elixir/apps/api/test/api/controllers/gateway_group_controller_test.exs new file mode 100644 index 000000000..d889dd963 --- /dev/null +++ b/elixir/apps/api/test/api/controllers/gateway_group_controller_test.exs @@ -0,0 +1,308 @@ +defmodule API.GatewayGroupControllerTest do + use API.ConnCase, async: true + alias Domain.Gateways.Group + alias Domain.Tokens.Token + + setup do + account = Fixtures.Accounts.create_account() + actor = Fixtures.Actors.create_actor(type: :api_client, account: account) + + %{ + account: account, + actor: actor + } + end + + describe "index/2" do + test "returns error when not authorized", %{conn: conn} do + conn = get(conn, "/gateway_groups") + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "lists all gateway groups", %{conn: conn, account: account, actor: actor} do + gateway_groups = for _ <- 1..3, do: Fixtures.Gateways.create_group(%{account: account}) + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> get("/gateway_groups") + + assert %{ + "data" => data, + "metadata" => %{ + "count" => count, + "limit" => limit, + "next_page" => next_page, + "prev_page" => prev_page + } + } = json_response(conn, 200) + + assert count == 3 + assert limit == 50 + assert is_nil(next_page) + assert is_nil(prev_page) + + data_ids = Enum.map(data, & &1["id"]) + gateway_group_ids = Enum.map(gateway_groups, & &1.id) + + assert equal_ids?(data_ids, gateway_group_ids) + end + + test "lists gateway groups with limit", %{conn: conn, account: account, actor: actor} do + gateway_groups = for _ <- 1..3, do: Fixtures.Gateways.create_group(%{account: account}) + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> get("/gateway_groups", limit: "2") + + assert %{ + "data" => data, + "metadata" => %{ + "count" => count, + "limit" => limit, + "next_page" => next_page, + "prev_page" => prev_page + } + } = json_response(conn, 200) + + assert limit == 2 + assert count == 3 + refute is_nil(next_page) + assert is_nil(prev_page) + + data_ids = Enum.map(data, & &1["id"]) |> MapSet.new() + gateway_group_ids = Enum.map(gateway_groups, & &1.id) |> MapSet.new() + + assert MapSet.subset?(data_ids, gateway_group_ids) + end + end + + describe "show/2" do + test "returns error when not authorized", %{conn: conn, account: account} do + gateway_group = Fixtures.Gateways.create_group(%{account: account}) + conn = get(conn, "/gateway_groups/#{gateway_group.id}") + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "returns a single gateway_group", %{conn: conn, account: account, actor: actor} do + gateway_group = Fixtures.Gateways.create_group(%{account: account}) + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> get("/gateway_groups/#{gateway_group.id}") + + assert json_response(conn, 200) == %{ + "data" => %{ + "id" => gateway_group.id, + "name" => gateway_group.name + } + } + end + end + + describe "create/2" do + test "returns error when not authorized", %{conn: conn} do + conn = post(conn, "/gateway_groups", %{}) + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "returns error on empty params/body", %{conn: conn, actor: actor} do + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> post("/gateway_groups") + + assert resp = json_response(conn, 400) + assert resp == %{"error" => %{"reason" => "Bad Request"}} + end + + test "returns error on invalid attrs", %{conn: conn, actor: actor} do + attrs = %{"name" => String.duplicate("a", 65)} + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> post("/gateway_groups", gateway_group: attrs) + + assert resp = json_response(conn, 422) + + assert resp == + %{ + "error" => %{ + "reason" => "Unprocessable Entity", + "validation_errors" => %{"name" => ["should be at most 64 character(s)"]} + } + } + end + + test "creates a gateway group with valid attrs", %{conn: conn, actor: actor} do + attrs = %{ + "name" => "Example Site" + } + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> post("/gateway_groups", gateway_group: attrs) + + assert resp = json_response(conn, 201) + + assert resp["data"]["name"] == attrs["name"] + end + end + + describe "update/2" do + test "returns error when not authorized", %{conn: conn, account: account} do + gateway_group = Fixtures.Gateways.create_group(%{account: account}) + conn = put(conn, "/gateway_groups/#{gateway_group.id}") + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "returns error on empty params/body", %{conn: conn, account: account, actor: actor} do + gateway_group = Fixtures.Gateways.create_group(%{account: account}) + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> put("/gateway_groups/#{gateway_group.id}") + + assert resp = json_response(conn, 400) + assert resp == %{"error" => %{"reason" => "Bad Request"}} + end + + test "updates a gateway group", %{conn: conn, account: account, actor: actor} do + gateway_group = Fixtures.Gateways.create_group(%{account: account}) + + attrs = %{"name" => "Updated Site Name"} + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> put("/gateway_groups/#{gateway_group.id}", gateway_group: attrs) + + assert resp = json_response(conn, 200) + + assert resp["data"]["name"] == attrs["name"] + end + end + + describe "delete/2" do + test "returns error when not authorized", %{conn: conn, account: account} do + gateway_group = Fixtures.Gateways.create_group(%{account: account}) + conn = delete(conn, "/gateway_groups/#{gateway_group.id}", %{}) + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "deletes a gateway group", %{conn: conn, account: account, actor: actor} do + gateway_group = Fixtures.Gateways.create_group(%{account: account}) + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> delete("/gateway_groups/#{gateway_group.id}") + + assert json_response(conn, 200) == %{ + "data" => %{ + "id" => gateway_group.id, + "name" => gateway_group.name + } + } + + assert gateway_group = Repo.get(Group, gateway_group.id) + assert gateway_group.deleted_at + end + end + + describe "gateway group token create/2" do + test "returns error when not authorized", %{conn: conn, account: account} do + gateway_group = Fixtures.Gateways.create_group(%{account: account}) + conn = post(conn, "/gateway_groups/#{gateway_group.id}/tokens") + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "creates a gateway token", %{ + conn: conn, + account: account, + actor: actor + } do + gateway_group = Fixtures.Gateways.create_group(%{account: account}) + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> post("/gateway_groups/#{gateway_group.id}/tokens") + + assert %{"data" => %{"id" => _id, "token" => _token}} = json_response(conn, 201) + end + end + + describe "delete single gateway token" do + test "returns error when not authorized", %{conn: conn, account: account} do + gateway_group = Fixtures.Gateways.create_group(%{account: account}) + token = Fixtures.Gateways.create_token(%{account: account, group: gateway_group}) + conn = delete(conn, "/gateway_groups/#{gateway_group.id}/tokens/#{token.id}") + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "deletes gateway token", %{conn: conn, account: account, actor: actor} do + gateway_group = Fixtures.Gateways.create_group(%{account: account}) + token = Fixtures.Gateways.create_token(%{account: account, group: gateway_group}) + + conn = + conn + |> authorize_conn(actor) + |> delete("/gateway_groups/#{gateway_group.id}/tokens/#{token.id}") + + assert %{"data" => %{"id" => _id}} = json_response(conn, 200) + + assert token = Repo.get(Token, token.id) + assert token.deleted_at + end + end + + describe "delete all gateway tokens" do + test "returns error when not authorized", %{conn: conn, account: account} do + gateway_group = Fixtures.Gateways.create_group(%{account: account}) + conn = delete(conn, "/gateway_groups/#{gateway_group.id}/tokens") + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "deletes all gateway tokens", %{ + conn: conn, + account: account, + actor: actor + } do + gateway_group = Fixtures.Gateways.create_group(%{account: account}) + + tokens = + for _ <- 1..3, + do: Fixtures.Gateways.create_token(%{account: account, group: gateway_group}) + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> delete("/gateway_groups/#{gateway_group.id}/tokens") + + assert %{"data" => [%{"id" => _id1}, %{"id" => _id2}, %{"id" => _id3}]} = + json_response(conn, 200) + + Enum.map(tokens, fn token -> + assert token = Repo.get(Token, token.id) + assert token.deleted_at + end) + end + end +end diff --git a/elixir/apps/api/test/api/controllers/identity_controller_test.exs b/elixir/apps/api/test/api/controllers/identity_controller_test.exs new file mode 100644 index 000000000..f0cf11e53 --- /dev/null +++ b/elixir/apps/api/test/api/controllers/identity_controller_test.exs @@ -0,0 +1,246 @@ +defmodule API.IdentityControllerTest do + use API.ConnCase, async: true + alias Domain.Auth.Identity + + setup do + account = Fixtures.Accounts.create_account() + actor = Fixtures.Actors.create_actor(type: :api_client, account: account) + + %{ + account: account, + actor: actor + } + end + + describe "index/2" do + test "returns error when not authorized", %{conn: conn, actor: actor} do + conn = get(conn, "/actors/#{actor.id}/identities") + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "lists all identities for actor", %{conn: conn, account: account, actor: actor} do + identities = + for _ <- 1..3, do: Fixtures.Auth.create_identity(%{account: account, actor: actor}) + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> get("/actors/#{actor.id}/identities") + + assert %{ + "data" => data, + "metadata" => %{ + "count" => count, + "limit" => limit, + "next_page" => next_page, + "prev_page" => prev_page + } + } = json_response(conn, 200) + + assert count == 3 + assert limit == 50 + assert is_nil(next_page) + assert is_nil(prev_page) + + data_ids = Enum.map(data, & &1["id"]) + identity_ids = Enum.map(identities, & &1.id) + + assert equal_ids?(data_ids, identity_ids) + end + + test "lists resources with limit", %{conn: conn, account: account, actor: actor} do + identities = + for _ <- 1..3, do: Fixtures.Auth.create_identity(%{account: account, actor: actor}) + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> get("/actors/#{actor.id}/identities", limit: "2") + + assert %{ + "data" => data, + "metadata" => %{ + "count" => count, + "limit" => limit, + "next_page" => next_page, + "prev_page" => prev_page + } + } = json_response(conn, 200) + + assert limit == 2 + assert count == 3 + refute is_nil(next_page) + assert is_nil(prev_page) + + data_ids = Enum.map(data, & &1["id"]) |> MapSet.new() + identity_ids = Enum.map(identities, & &1.id) |> MapSet.new() + + assert MapSet.subset?(data_ids, identity_ids) + end + end + + describe "show/2" do + test "returns error when not authorized", %{conn: conn, account: account, actor: actor} do + identity = Fixtures.Auth.create_identity(%{account: account, actor: actor}) + conn = get(conn, "/actors/#{actor.id}/identities/#{identity.id}") + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "returns a single resource", %{conn: conn, account: account, actor: actor} do + identity = Fixtures.Auth.create_identity(%{account: account, actor: actor}) + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> get("/actors/#{actor.id}/identities/#{identity.id}") + + assert json_response(conn, 200) == %{ + "data" => %{ + "id" => identity.id, + "actor_id" => actor.id, + "provider_id" => identity.provider_id, + "provider_identifier" => identity.provider_identifier + } + } + end + end + + describe "create/2" do + test "returns error when not authorized", %{conn: conn, actor: actor} do + conn = post(conn, "/actors/#{actor.id}/identities", %{}) + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "returns error on empty params/body", %{ + conn: conn, + account: account, + actor: api_actor + } do + actor = Fixtures.Actors.create_actor(account: account) + + conn = + conn + |> authorize_conn(api_actor) + |> put_req_header("content-type", "application/json") + |> post("/actors/#{actor.id}/identities") + + assert resp = json_response(conn, 400) + assert resp == %{"error" => %{"reason" => "Bad Request"}} + end + + test "returns error on invalid identity provider", %{ + conn: conn, + account: account, + actor: api_actor + } do + actor = Fixtures.Actors.create_actor(account: account) + + attrs = %{} + + conn = + conn + |> authorize_conn(api_actor) + |> put_req_header("content-type", "application/json") + |> post("/actors/#{actor.id}/identities", + provider_id: "1234", + identity: attrs + ) + + assert resp = json_response(conn, 404) + assert resp == %{"error" => %{"reason" => "Not Found"}} + end + + test "returns error on invalid identity attrs", %{ + conn: conn, + account: account, + actor: api_actor + } do + {oidc_provider, _bypass} = + Fixtures.Auth.start_and_create_openid_connect_provider(account: account) + + actor = Fixtures.Actors.create_actor(account: account) + + attrs = %{} + + conn = + conn + |> authorize_conn(api_actor) + |> put_req_header("content-type", "application/json") + |> post("/actors/#{actor.id}/identities", + provider_id: oidc_provider.id, + identity: attrs + ) + + assert resp = json_response(conn, 422) + + assert resp == + %{ + "error" => %{ + "reason" => "Unprocessable Entity", + "validation_errors" => %{"provider_identifier" => ["can't be blank"]} + } + } + end + + test "creates a resource with valid attrs", %{ + conn: conn, + account: account, + actor: api_actor + } do + {oidc_provider, _bypass} = + Fixtures.Auth.start_and_create_openid_connect_provider(account: account) + + actor = Fixtures.Actors.create_actor(account: account) + + attrs = %{"provider_identifier" => "foo@local"} + + conn = + conn + |> authorize_conn(api_actor) + |> put_req_header("content-type", "application/json") + |> post("/actors/#{actor.id}/identities", + provider_id: oidc_provider.id, + identity: attrs + ) + + assert resp = json_response(conn, 201) + + assert resp["data"]["provider_identifier"] == attrs["provider_identifier"] + assert resp["data"]["provider_id"] == oidc_provider.id + assert resp["data"]["actor_id"] == actor.id + end + end + + describe "delete/2" do + test "returns error when not authorized", %{conn: conn, account: account, actor: actor} do + identity = Fixtures.Auth.create_identity(%{account: account, actor: actor}) + conn = delete(conn, "/actors/#{actor.id}/identities/#{identity.id}") + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "deletes a resource", %{conn: conn, account: account, actor: actor} do + identity = Fixtures.Auth.create_identity(%{account: account, actor: actor}) + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> delete("/actors/#{actor.id}/identities/#{identity.id}") + + assert json_response(conn, 200) == %{ + "data" => %{ + "id" => identity.id, + "actor_id" => actor.id, + "provider_id" => identity.provider_id, + "provider_identifier" => identity.provider_identifier + } + } + + assert identity = Repo.get(Identity, identity.id) + assert identity.deleted_at + end + end +end diff --git a/elixir/apps/api/test/api/controllers/identity_provider_controller_test.exs b/elixir/apps/api/test/api/controllers/identity_provider_controller_test.exs new file mode 100644 index 000000000..6f3b7bb3d --- /dev/null +++ b/elixir/apps/api/test/api/controllers/identity_provider_controller_test.exs @@ -0,0 +1,146 @@ +defmodule API.IdentityProviderControllerTest do + use API.ConnCase, async: true + alias Domain.Auth.Provider + + setup do + account = Fixtures.Accounts.create_account() + actor = Fixtures.Actors.create_actor(type: :api_client, account: account) + + %{ + account: account, + actor: actor + } + end + + describe "index/2" do + test "returns error when not authorized", %{conn: conn} do + conn = get(conn, "/identity_providers") + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "lists all identity_providers", %{conn: conn, account: account, actor: actor} do + {oidc_provider, _bypass} = + Fixtures.Auth.start_and_create_openid_connect_provider(account: account) + + {google_provider, _bypass} = + Fixtures.Auth.start_and_create_google_workspace_provider(%{account: account}) + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> get("/identity_providers") + + assert %{ + "data" => data, + "metadata" => %{ + "count" => count, + "limit" => limit, + "next_page" => next_page, + "prev_page" => prev_page + } + } = json_response(conn, 200) + + assert count == 3 + assert limit == 50 + assert is_nil(next_page) + assert is_nil(prev_page) + + data_ids = Enum.map(data, & &1["id"]) |> MapSet.new() + + provider_ids = + Enum.map([oidc_provider, google_provider], & &1.id) |> MapSet.new() + + assert MapSet.subset?(provider_ids, data_ids) + end + + test "lists identity providers with limit", %{conn: conn, account: account, actor: actor} do + Fixtures.Auth.start_and_create_openid_connect_provider(%{account: account}) + Fixtures.Auth.start_and_create_google_workspace_provider(%{account: account}) + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> get("/identity_providers", limit: "2") + + assert %{ + "data" => data, + "metadata" => %{ + "count" => count, + "limit" => limit, + "next_page" => next_page, + "prev_page" => prev_page + } + } = json_response(conn, 200) + + assert limit == 2 + assert count == 3 + refute is_nil(next_page) + assert is_nil(prev_page) + + data_ids = Enum.map(data, & &1["id"]) + + assert length(data_ids) == 2 + end + end + + describe "show/2" do + test "returns error when not authorized", %{conn: conn, account: account} do + {identity_provider, _bypass} = + Fixtures.Auth.start_and_create_openid_connect_provider(%{account: account}) + + conn = get(conn, "/identity_providers/#{identity_provider.id}") + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "returns a single resource", %{conn: conn, account: account, actor: actor} do + {identity_provider, _bypass} = + Fixtures.Auth.start_and_create_openid_connect_provider(%{account: account}) + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> get("/identity_providers/#{identity_provider.id}") + + assert json_response(conn, 200) == %{ + "data" => %{ + "id" => identity_provider.id, + "name" => identity_provider.name + } + } + end + end + + describe "delete/2" do + test "returns error when not authorized", %{conn: conn, account: account} do + {identity_provider, _bypass} = + Fixtures.Auth.start_and_create_openid_connect_provider(%{account: account}) + + conn = delete(conn, "/identity_providers/#{identity_provider.id}") + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "deletes an identity provider", %{conn: conn, account: account, actor: actor} do + {identity_provider, _bypass} = + Fixtures.Auth.start_and_create_openid_connect_provider(%{account: account}) + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> delete("/identity_providers/#{identity_provider.id}") + + assert json_response(conn, 200) == %{ + "data" => %{ + "id" => identity_provider.id, + "name" => identity_provider.name + } + } + + assert identity_provider = Repo.get(Provider, identity_provider.id) + assert identity_provider.deleted_at + end + end +end diff --git a/elixir/apps/api/test/api/controllers/policy_controller_test.exs b/elixir/apps/api/test/api/controllers/policy_controller_test.exs new file mode 100644 index 000000000..91e3dd213 --- /dev/null +++ b/elixir/apps/api/test/api/controllers/policy_controller_test.exs @@ -0,0 +1,238 @@ +defmodule API.PolicyControllerTest do + use API.ConnCase, async: true + alias Domain.Policies.Policy + + setup do + account = Fixtures.Accounts.create_account() + actor = Fixtures.Actors.create_actor(type: :api_client, account: account) + + %{ + account: account, + actor: actor + } + end + + describe "index/2" do + test "returns error when not authorized", %{conn: conn} do + conn = get(conn, "/policies") + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "lists all policies", %{conn: conn, account: account, actor: actor} do + policies = for _ <- 1..3, do: Fixtures.Policies.create_policy(%{account: account}) + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> get("/policies", Jason.encode!(%{})) + + assert %{ + "data" => data, + "metadata" => %{ + "count" => count, + "limit" => limit, + "next_page" => next_page, + "prev_page" => prev_page + } + } = json_response(conn, 200) + + assert count == 3 + assert limit == 50 + assert is_nil(next_page) + assert is_nil(prev_page) + + data_ids = Enum.map(data, & &1["id"]) + policy_ids = Enum.map(policies, & &1.id) + + assert equal_ids?(data_ids, policy_ids) + end + + test "lists policies with limit", %{conn: conn, account: account, actor: actor} do + policies = for _ <- 1..3, do: Fixtures.Policies.create_policy(%{account: account}) + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> get("/policies", limit: "2") + + assert %{ + "data" => data, + "metadata" => %{ + "count" => count, + "limit" => limit, + "next_page" => next_page, + "prev_page" => prev_page + } + } = json_response(conn, 200) + + assert limit == 2 + assert count == 3 + refute is_nil(next_page) + assert is_nil(prev_page) + + data_ids = Enum.map(data, & &1["id"]) |> MapSet.new() + policy_ids = Enum.map(policies, & &1.id) |> MapSet.new() + + assert MapSet.subset?(data_ids, policy_ids) + end + end + + describe "show/2" do + test "returns error when not authorized", %{conn: conn, account: account} do + policy = Fixtures.Policies.create_policy(%{account: account}) + conn = get(conn, "/policies/#{policy.id}") + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "returns a single policy", %{conn: conn, account: account, actor: actor} do + policy = Fixtures.Policies.create_policy(%{account: account}) + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> get("/policies/#{policy.id}") + + assert json_response(conn, 200) == %{ + "data" => %{ + "id" => policy.id, + "actor_group_id" => policy.actor_group_id, + "resource_id" => policy.resource_id, + "description" => policy.description + } + } + end + end + + describe "create/2" do + test "returns error when not authorized", %{conn: conn} do + conn = post(conn, "/policies", %{}) + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "returns error on empty params/body", %{conn: conn, actor: actor} do + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> post("/policies") + + assert resp = json_response(conn, 400) + assert resp == %{"error" => %{"reason" => "Bad Request"}} + end + + test "returns error on invalid attrs", %{conn: conn, actor: actor} do + attrs = %{} + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> post("/policies", policy: attrs) + + assert resp = json_response(conn, 422) + + assert resp == + %{ + "error" => %{ + "reason" => "Unprocessable Entity", + "validation_errors" => %{ + "actor_group_id" => ["can't be blank"], + "resource_id" => ["can't be blank"] + } + } + } + end + + test "creates a policy with valid attrs", %{conn: conn, account: account, actor: actor} do + resource = Fixtures.Resources.create_resource(%{account: account}) + actor_group = Fixtures.Actors.create_group(%{account: account}) + + attrs = %{ + "actor_group_id" => actor_group.id, + "resource_id" => resource.id, + "description" => "test policy" + } + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> post("/policies", policy: attrs) + + assert resp = json_response(conn, 201) + + assert resp["data"]["actor_group_id"] == attrs["actor_group_id"] + assert resp["data"]["resource_id"] == attrs["resource_id"] + end + end + + describe "update/2" do + test "returns error when not authorized", %{conn: conn, account: account} do + policy = Fixtures.Policies.create_policy(%{account: account}) + conn = put(conn, "/policies/#{policy.id}", %{}) + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "returns error on empty params/body", %{conn: conn, account: account, actor: actor} do + policy = Fixtures.Policies.create_policy(%{account: account}) + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> put("/policies/#{policy.id}") + + assert resp = json_response(conn, 400) + assert resp == %{"error" => %{"reason" => "Bad Request"}} + end + + test "updates a policy", %{conn: conn, account: account, actor: actor} do + policy = Fixtures.Policies.create_policy(%{account: account}) + + attrs = %{"description" => "updated policy description"} + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> put("/policies/#{policy.id}", policy: attrs) + + assert resp = json_response(conn, 200) + + assert resp["data"]["description"] == attrs["description"] + end + end + + describe "delete/2" do + test "returns error when not authorized", %{conn: conn, account: account} do + policy = Fixtures.Policies.create_policy(%{account: account}) + conn = delete(conn, "/policies/#{policy.id}", %{}) + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "deletes a policy", %{conn: conn, account: account, actor: actor} do + policy = Fixtures.Policies.create_policy(%{account: account}) + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> delete("/policies/#{policy.id}") + + assert json_response(conn, 200) == %{ + "data" => %{ + "id" => policy.id, + "actor_group_id" => policy.actor_group_id, + "resource_id" => policy.resource_id, + "description" => policy.description + } + } + + assert policy = Repo.get(Policy, policy.id) + assert policy.deleted_at + end + end +end diff --git a/elixir/apps/api/test/api/controllers/resource_controller_test.exs b/elixir/apps/api/test/api/controllers/resource_controller_test.exs new file mode 100644 index 000000000..b9e31b3f8 --- /dev/null +++ b/elixir/apps/api/test/api/controllers/resource_controller_test.exs @@ -0,0 +1,248 @@ +defmodule API.ResourceControllerTest do + use API.ConnCase, async: true + alias Domain.Resources.Resource + + setup do + account = Fixtures.Accounts.create_account() + actor = Fixtures.Actors.create_actor(type: :api_client, account: account) + + %{ + account: account, + actor: actor + } + end + + describe "index/2" do + test "returns error when not authorized", %{conn: conn} do + conn = get(conn, "/resources") + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "lists all resources", %{conn: conn, account: account, actor: actor} do + resources = for _ <- 1..3, do: Fixtures.Resources.create_resource(%{account: account}) + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> get("/resources") + + assert %{ + "data" => data, + "metadata" => %{ + "count" => count, + "limit" => limit, + "next_page" => next_page, + "prev_page" => prev_page + } + } = json_response(conn, 200) + + assert count == 3 + assert limit == 50 + assert is_nil(next_page) + assert is_nil(prev_page) + + data_ids = Enum.map(data, & &1["id"]) + resource_ids = Enum.map(resources, & &1.id) + + assert equal_ids?(data_ids, resource_ids) + end + + test "lists resources with limit", %{conn: conn, account: account, actor: actor} do + resources = for _ <- 1..3, do: Fixtures.Resources.create_resource(%{account: account}) + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> get("/resources", limit: "2") + + assert %{ + "data" => data, + "metadata" => %{ + "count" => count, + "limit" => limit, + "next_page" => next_page, + "prev_page" => prev_page + } + } = json_response(conn, 200) + + assert limit == 2 + assert count == 3 + refute is_nil(next_page) + assert is_nil(prev_page) + + data_ids = Enum.map(data, & &1["id"]) |> MapSet.new() + resource_ids = Enum.map(resources, & &1.id) |> MapSet.new() + + assert MapSet.subset?(data_ids, resource_ids) + end + end + + describe "show/2" do + test "returns error when not authorized", %{conn: conn, account: account} do + resource = Fixtures.Resources.create_resource(%{account: account}) + conn = get(conn, "/resources/#{resource.id}") + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "returns a single resource", %{conn: conn, account: account, actor: actor} do + resource = Fixtures.Resources.create_resource(%{account: account}) + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> get("/resources/#{resource.id}") + + assert json_response(conn, 200) == %{ + "data" => %{ + "address" => resource.address, + "description" => resource.address_description, + "id" => resource.id, + "name" => resource.name, + "type" => Atom.to_string(resource.type) + } + } + end + end + + describe "create/2" do + test "returns error when not authorized", %{conn: conn} do + conn = post(conn, "/resources", %{}) + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "returns error on empty params/body", %{conn: conn, actor: actor} do + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> post("/resources") + + assert resp = json_response(conn, 400) + assert resp == %{"error" => %{"reason" => "Bad Request"}} + end + + test "returns error on invalid attrs", %{conn: conn, actor: actor} do + attrs = %{} + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> post("/resources", resource: attrs) + + assert resp = json_response(conn, 422) + + assert resp == + %{ + "error" => %{ + "reason" => "Unprocessable Entity", + "validation_errors" => %{ + "address" => ["can't be blank"], + "connections" => ["can't be blank"], + "name" => ["can't be blank"], + "type" => ["can't be blank"] + } + } + } + end + + test "creates a resource with valid attrs", %{conn: conn, account: account, actor: actor} do + gateway_group = Fixtures.Gateways.create_group(%{account: account}) + + attrs = %{ + "address" => "google.com", + "name" => "Google", + "type" => "dns", + "connections" => [ + %{"gateway_group_id" => gateway_group.id} + ] + } + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> post("/resources", resource: attrs) + + assert resp = json_response(conn, 201) + + assert resp["data"]["address"] == attrs["address"] + assert resp["data"]["description"] == nil + assert resp["data"]["name"] == attrs["name"] + assert resp["data"]["type"] == attrs["type"] + end + end + + describe "update/2" do + test "returns error when not authorized", %{conn: conn, account: account} do + resource = Fixtures.Resources.create_resource(%{account: account}) + conn = put(conn, "/resources/#{resource.id}", %{}) + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "returns error on empty params/body", %{conn: conn, account: account, actor: actor} do + resource = Fixtures.Resources.create_resource(%{account: account}) + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> put("/resources/#{resource.id}") + + assert resp = json_response(conn, 400) + assert resp == %{"error" => %{"reason" => "Bad Request"}} + end + + test "updates a resource", %{conn: conn, account: account, actor: actor} do + resource = Fixtures.Resources.create_resource(%{account: account}) + + attrs = %{"name" => "Google"} + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> put("/resources/#{resource.id}", resource: attrs) + + assert resp = json_response(conn, 200) + + assert resp["data"]["address"] == resource.address + assert resp["data"]["description"] == resource.address_description + assert resp["data"]["name"] == attrs["name"] + end + end + + describe "delete/2" do + test "returns error when not authorized", %{conn: conn, account: account} do + resource = Fixtures.Resources.create_resource(%{account: account}) + conn = delete(conn, "/resources/#{resource.id}") + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "deletes a resource", %{conn: conn, account: account, actor: actor} do + resource = Fixtures.Resources.create_resource(%{account: account}) + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> delete("/resources/#{resource.id}") + + assert json_response(conn, 200) == %{ + "data" => %{ + "address" => resource.address, + "description" => resource.address_description, + "id" => resource.id, + "name" => resource.name, + "type" => Atom.to_string(resource.type) + } + } + + assert resource = Repo.get(Resource, resource.id) + assert resource.deleted_at + end + end +end diff --git a/elixir/apps/api/test/support/conn_case.ex b/elixir/apps/api/test/support/conn_case.ex index bef9abca8..1949b8a73 100644 --- a/elixir/apps/api/test/support/conn_case.ex +++ b/elixir/apps/api/test/support/conn_case.ex @@ -56,4 +56,8 @@ defmodule API.ConnCase do Plug.Conn.put_req_header(conn, "authorization", "Bearer " <> encoded_fragment) end + + def equal_ids?(list1, list2) do + MapSet.equal?(MapSet.new(list1), MapSet.new(list2)) + end end diff --git a/elixir/apps/domain/lib/domain/accounts/authorizer.ex b/elixir/apps/domain/lib/domain/accounts/authorizer.ex index a1bb6648c..5c3e04445 100644 --- a/elixir/apps/domain/lib/domain/accounts/authorizer.ex +++ b/elixir/apps/domain/lib/domain/accounts/authorizer.ex @@ -11,6 +11,12 @@ defmodule Domain.Accounts.Authorizer do ] end + def list_permissions_for_role(:api_client) do + [ + manage_own_account_permission() + ] + end + def list_permissions_for_role(_) do [] end diff --git a/elixir/apps/domain/lib/domain/actors/authorizer.ex b/elixir/apps/domain/lib/domain/actors/authorizer.ex index a8b243d91..c0954c4a5 100644 --- a/elixir/apps/domain/lib/domain/actors/authorizer.ex +++ b/elixir/apps/domain/lib/domain/actors/authorizer.ex @@ -13,6 +13,13 @@ defmodule Domain.Actors.Authorizer do ] end + def list_permissions_for_role(:api_client) do + [ + manage_actors_permission(), + edit_own_profile_permission() + ] + end + def list_permissions_for_role(:account_user) do [ edit_own_profile_permission() diff --git a/elixir/apps/domain/lib/domain/actors/group.ex b/elixir/apps/domain/lib/domain/actors/group.ex index 836d49781..d36b90ba7 100644 --- a/elixir/apps/domain/lib/domain/actors/group.ex +++ b/elixir/apps/domain/lib/domain/actors/group.ex @@ -20,8 +20,9 @@ defmodule Domain.Actors.Group do # ref https://github.com/firezone/firezone/issues/2162 has_many :actors, through: [:memberships, :actor] - field :created_by, Ecto.Enum, values: ~w[system identity provider]a + field :created_by, Ecto.Enum, values: ~w[actor identity provider system]a belongs_to :created_by_identity, Domain.Auth.Identity + belongs_to :created_by_actor, Domain.Actors.Actor belongs_to :account, Domain.Accounts.Account diff --git a/elixir/apps/domain/lib/domain/actors/group/changeset.ex b/elixir/apps/domain/lib/domain/actors/group/changeset.ex index a6e6c1124..7cfe677d0 100644 --- a/elixir/apps/domain/lib/domain/actors/group/changeset.ex +++ b/elixir/apps/domain/lib/domain/actors/group/changeset.ex @@ -19,8 +19,7 @@ defmodule Domain.Actors.Group.Changeset do |> changeset() |> put_change(:account_id, account.id) |> cast_membership_assocs(account.id) - |> put_change(:created_by, :identity) - |> put_change(:created_by_identity_id, subject.identity.id) + |> put_created_by(subject) end def create(%Accounts.Account{} = account, attrs) do diff --git a/elixir/apps/domain/lib/domain/auth/authorizer.ex b/elixir/apps/domain/lib/domain/auth/authorizer.ex index f1dae9625..062555c4a 100644 --- a/elixir/apps/domain/lib/domain/auth/authorizer.ex +++ b/elixir/apps/domain/lib/domain/auth/authorizer.ex @@ -54,6 +54,15 @@ defmodule Domain.Auth.Authorizer do ] end + def list_permissions_for_role(:api_client) do + [ + manage_providers_permission(), + manage_service_accounts_permission(), + manage_own_identities_permission(), + manage_identities_permission() + ] + end + def list_permissions_for_role(_role) do [] end diff --git a/elixir/apps/domain/lib/domain/clients/authorizer.ex b/elixir/apps/domain/lib/domain/clients/authorizer.ex index 415a0dfaa..c1c77489b 100644 --- a/elixir/apps/domain/lib/domain/clients/authorizer.ex +++ b/elixir/apps/domain/lib/domain/clients/authorizer.ex @@ -14,6 +14,13 @@ defmodule Domain.Clients.Authorizer do ] end + def list_permissions_for_role(:api_client) do + [ + manage_own_clients_permission(), + manage_clients_permission() + ] + end + def list_permissions_for_role(:account_user) do [ manage_own_clients_permission() diff --git a/elixir/apps/domain/lib/domain/flows/authorizer.ex b/elixir/apps/domain/lib/domain/flows/authorizer.ex index 52e392b0a..e258dedf8 100644 --- a/elixir/apps/domain/lib/domain/flows/authorizer.ex +++ b/elixir/apps/domain/lib/domain/flows/authorizer.ex @@ -13,6 +13,13 @@ defmodule Domain.Flows.Authorizer do ] end + def list_permissions_for_role(:api_client) do + [ + manage_flows_permission(), + create_flows_permission() + ] + end + def list_permissions_for_role(:account_user) do [ create_flows_permission() diff --git a/elixir/apps/domain/lib/domain/gateways/authorizer.ex b/elixir/apps/domain/lib/domain/gateways/authorizer.ex index b9c53d421..63649308d 100644 --- a/elixir/apps/domain/lib/domain/gateways/authorizer.ex +++ b/elixir/apps/domain/lib/domain/gateways/authorizer.ex @@ -14,6 +14,13 @@ defmodule Domain.Gateways.Authorizer do ] end + def list_permissions_for_role(:api_client) do + [ + manage_gateways_permission(), + connect_gateways_permission() + ] + end + def list_permissions_for_role(_) do [ connect_gateways_permission() diff --git a/elixir/apps/domain/lib/domain/gateways/group.ex b/elixir/apps/domain/lib/domain/gateways/group.ex index 9cf355a11..5df689c34 100644 --- a/elixir/apps/domain/lib/domain/gateways/group.ex +++ b/elixir/apps/domain/lib/domain/gateways/group.ex @@ -13,8 +13,9 @@ defmodule Domain.Gateways.Group do has_many :connections, Domain.Resources.Connection, foreign_key: :gateway_group_id - field :created_by, Ecto.Enum, values: ~w[identity]a + field :created_by, Ecto.Enum, values: ~w[actor identity]a belongs_to :created_by_identity, Domain.Auth.Identity + belongs_to :created_by_actor, Domain.Actors.Actor field :deleted_at, :utc_datetime_usec timestamps() diff --git a/elixir/apps/domain/lib/domain/gateways/group/changeset.ex b/elixir/apps/domain/lib/domain/gateways/group/changeset.ex index 016c32a73..349a6bcb0 100644 --- a/elixir/apps/domain/lib/domain/gateways/group/changeset.ex +++ b/elixir/apps/domain/lib/domain/gateways/group/changeset.ex @@ -9,8 +9,7 @@ defmodule Domain.Gateways.Group.Changeset do %Gateways.Group{account: account} |> changeset(attrs) |> put_change(:account_id, account.id) - |> put_change(:created_by, :identity) - |> put_change(:created_by_identity_id, subject.identity.id) + |> put_created_by(subject) end def update(%Gateways.Group{} = group, attrs, %Auth.Subject{}) do diff --git a/elixir/apps/domain/lib/domain/policies/authorizer.ex b/elixir/apps/domain/lib/domain/policies/authorizer.ex index ef48dddeb..de140ead1 100644 --- a/elixir/apps/domain/lib/domain/policies/authorizer.ex +++ b/elixir/apps/domain/lib/domain/policies/authorizer.ex @@ -13,6 +13,13 @@ defmodule Domain.Policies.Authorizer do ] end + def list_permissions_for_role(:api_client) do + [ + manage_policies_permission(), + view_available_policies_permission() + ] + end + def list_permissions_for_role(:account_user) do [ # TODO: view_assigned_policies_permission() diff --git a/elixir/apps/domain/lib/domain/policies/policy.ex b/elixir/apps/domain/lib/domain/policies/policy.ex index e9184bf5b..5eeac8be8 100644 --- a/elixir/apps/domain/lib/domain/policies/policy.ex +++ b/elixir/apps/domain/lib/domain/policies/policy.ex @@ -10,8 +10,9 @@ defmodule Domain.Policies.Policy do belongs_to :resource, Domain.Resources.Resource belongs_to :account, Domain.Accounts.Account - field :created_by, Ecto.Enum, values: ~w[identity]a + field :created_by, Ecto.Enum, values: ~w[actor identity]a belongs_to :created_by_identity, Domain.Auth.Identity + belongs_to :created_by_actor, Domain.Actors.Actor field :disabled_at, :utc_datetime_usec field :deleted_at, :utc_datetime_usec diff --git a/elixir/apps/domain/lib/domain/policies/policy/changeset.ex b/elixir/apps/domain/lib/domain/policies/policy/changeset.ex index 5110a5777..1a879ab5b 100644 --- a/elixir/apps/domain/lib/domain/policies/policy/changeset.ex +++ b/elixir/apps/domain/lib/domain/policies/policy/changeset.ex @@ -14,8 +14,7 @@ defmodule Domain.Policies.Policy.Changeset do |> cast_embed(:conditions, with: &Domain.Policies.Condition.Changeset.changeset/3) |> changeset() |> put_change(:account_id, subject.account.id) - |> put_change(:created_by, :identity) - |> put_change(:created_by_identity_id, subject.identity.id) + |> put_created_by(subject) end def update(%Policy{} = policy, attrs) do diff --git a/elixir/apps/domain/lib/domain/repo/changeset.ex b/elixir/apps/domain/lib/domain/repo/changeset.ex index 0a3588f68..c54047b4c 100644 --- a/elixir/apps/domain/lib/domain/repo/changeset.ex +++ b/elixir/apps/domain/lib/domain/repo/changeset.ex @@ -139,6 +139,19 @@ defmodule Domain.Repo.Changeset do end end + def put_created_by(changset, %Domain.Auth.Subject{identity: nil} = subject) do + changset + |> put_change(:created_by_actor_id, subject.actor.id) + |> put_change(:created_by, :actor) + end + + def put_created_by(changeset, %Domain.Auth.Subject{} = subject) do + changeset + |> put_change(:created_by, :identity) + |> put_change(:created_by_identity_id, subject.identity.id) + |> put_change(:created_by_actor_id, subject.actor.id) + end + # Validations def validate_list( diff --git a/elixir/apps/domain/lib/domain/resources/authorizer.ex b/elixir/apps/domain/lib/domain/resources/authorizer.ex index eba213b68..990a41c6b 100644 --- a/elixir/apps/domain/lib/domain/resources/authorizer.ex +++ b/elixir/apps/domain/lib/domain/resources/authorizer.ex @@ -19,6 +19,13 @@ defmodule Domain.Resources.Authorizer do ] end + def list_permissions_for_role(:api_client) do + [ + manage_resources_permission(), + view_available_resources_permission() + ] + end + def list_permissions_for_role(:service_account) do [ view_available_resources_permission() diff --git a/elixir/apps/domain/lib/domain/resources/connection.ex b/elixir/apps/domain/lib/domain/resources/connection.ex index fbe1b5ee4..5c3f7caf9 100644 --- a/elixir/apps/domain/lib/domain/resources/connection.ex +++ b/elixir/apps/domain/lib/domain/resources/connection.ex @@ -6,8 +6,9 @@ defmodule Domain.Resources.Connection do belongs_to :resource, Domain.Resources.Resource, primary_key: true belongs_to :gateway_group, Domain.Gateways.Group, primary_key: true - field :created_by, Ecto.Enum, values: ~w[identity]a + field :created_by, Ecto.Enum, values: ~w[actor identity]a belongs_to :created_by_identity, Domain.Auth.Identity + belongs_to :created_by_actor, Domain.Actors.Actor belongs_to :account, Domain.Accounts.Account end diff --git a/elixir/apps/domain/lib/domain/resources/connection/changeset.ex b/elixir/apps/domain/lib/domain/resources/connection/changeset.ex index 5019ddab5..89a0504c3 100644 --- a/elixir/apps/domain/lib/domain/resources/connection/changeset.ex +++ b/elixir/apps/domain/lib/domain/resources/connection/changeset.ex @@ -7,8 +7,7 @@ defmodule Domain.Resources.Connection.Changeset do def changeset(account_id, connection, attrs, %Auth.Subject{} = subject) do changeset(account_id, connection, attrs) - |> put_change(:created_by, :identity) - |> put_change(:created_by_identity_id, subject.identity.id) + |> put_created_by(subject) end def changeset(account_id, connection, attrs) do diff --git a/elixir/apps/domain/lib/domain/resources/resource.ex b/elixir/apps/domain/lib/domain/resources/resource.ex index ed8f1af78..8faa3e4b8 100644 --- a/elixir/apps/domain/lib/domain/resources/resource.ex +++ b/elixir/apps/domain/lib/domain/resources/resource.ex @@ -26,7 +26,8 @@ defmodule Domain.Resources.Resource do # because the actual preload query should also use joins and process policy conditions has_many :authorized_by_policies, Domain.Policies.Policy, where: [id: {:fragment, "FALSE"}] - field :created_by, Ecto.Enum, values: ~w[identity]a + field :created_by, Ecto.Enum, values: ~w[identity actor]a + belongs_to :created_by_actor, Domain.Actors.Actor belongs_to :created_by_identity, Domain.Auth.Identity field :deleted_at, :utc_datetime_usec diff --git a/elixir/apps/domain/lib/domain/resources/resource/changeset.ex b/elixir/apps/domain/lib/domain/resources/resource/changeset.ex index 94852068e..f994e8f80 100644 --- a/elixir/apps/domain/lib/domain/resources/resource/changeset.ex +++ b/elixir/apps/domain/lib/domain/resources/resource/changeset.ex @@ -18,8 +18,7 @@ defmodule Domain.Resources.Resource.Changeset do with: &Connection.Changeset.changeset(account.id, &1, &2, subject), required: true ) - |> put_change(:created_by, :identity) - |> put_change(:created_by_identity_id, subject.identity.id) + |> put_created_by(subject) end def create(%Accounts.Account{} = account, attrs) do diff --git a/elixir/apps/domain/lib/domain/tokens/authorizer.ex b/elixir/apps/domain/lib/domain/tokens/authorizer.ex index 99adb61f4..48d768a35 100644 --- a/elixir/apps/domain/lib/domain/tokens/authorizer.ex +++ b/elixir/apps/domain/lib/domain/tokens/authorizer.ex @@ -13,6 +13,13 @@ defmodule Domain.Tokens.Authorizer do ] end + def list_permissions_for_role(:api_client) do + [ + manage_tokens_permission(), + manage_own_tokens_permission() + ] + end + def list_permissions_for_role(:account_user) do [ manage_own_tokens_permission() diff --git a/elixir/apps/domain/lib/domain/tokens/token.ex b/elixir/apps/domain/lib/domain/tokens/token.ex index f4216b6e8..712b62565 100644 --- a/elixir/apps/domain/lib/domain/tokens/token.ex +++ b/elixir/apps/domain/lib/domain/tokens/token.ex @@ -43,8 +43,9 @@ defmodule Domain.Tokens.Token do field :last_seen_at, :utc_datetime_usec # Maybe this is not needed and they should be in the join tables (eg. relay_group_tokens) - field :created_by, Ecto.Enum, values: ~w[system identity]a + field :created_by, Ecto.Enum, values: ~w[actor identity system]a belongs_to :created_by_identity, Domain.Auth.Identity + belongs_to :created_by_actor, Domain.Actors.Actor field :created_by_user_agent, :string field :created_by_remote_ip, Domain.Types.IP diff --git a/elixir/apps/domain/lib/domain/tokens/token/changeset.ex b/elixir/apps/domain/lib/domain/tokens/token/changeset.ex index 2f08af9b4..da2eea8b3 100644 --- a/elixir/apps/domain/lib/domain/tokens/token/changeset.ex +++ b/elixir/apps/domain/lib/domain/tokens/token/changeset.ex @@ -46,8 +46,7 @@ defmodule Domain.Tokens.Token.Changeset do :service_account_client ]) |> changeset() - |> put_change(:created_by, :identity) - |> put_change(:created_by_identity_id, subject.identity.id) + |> put_created_by(subject) end defp changeset(changeset) do diff --git a/elixir/apps/domain/priv/repo/migrations/20240618210715_add_created_by_actor.exs b/elixir/apps/domain/priv/repo/migrations/20240618210715_add_created_by_actor.exs new file mode 100644 index 000000000..80b0f5c07 --- /dev/null +++ b/elixir/apps/domain/priv/repo/migrations/20240618210715_add_created_by_actor.exs @@ -0,0 +1,37 @@ +defmodule Domain.Repo.Migrations.AddCreatedByActor do + use Ecto.Migration + + @table_names ~w[ + actor_groups + auth_identities + auth_providers + gateway_groups + policies + relay_groups + resources + resource_connections + tokens + ]a + + defp migrate_data(table_name) do + """ + UPDATE #{table_name} AS t + SET created_by_actor_id = ai.actor_id + FROM auth_identities AS ai + WHERE t.created_by_identity_id = ai.id + AND t.created_by = 'identity' + AND t.created_by_identity_id is not null; + """ + |> execute("") + end + + def change do + for table_name <- @table_names do + alter table(table_name) do + add(:created_by_actor_id, :uuid) + end + + migrate_data(table_name) + end + end +end diff --git a/elixir/config/dev.exs b/elixir/config/dev.exs index 1fca8e559..1f55052a5 100644 --- a/elixir/config/dev.exs +++ b/elixir/config/dev.exs @@ -95,6 +95,9 @@ config :api, API.Endpoint, # Do not include metadata nor timestamps in development logs config :logger, :default_formatter, format: "[$level] $message\n" +# Disable caching for OpenAPI spec to ensure it is refreshed +config :open_api_spex, :cache_adapter, OpenApiSpex.Plug.NoneCache + # Set a higher stacktrace during development. Avoid configuring such # in production as building large stacktraces may be expensive. config :phoenix, :stacktrace_depth, 20 diff --git a/elixir/mix.lock b/elixir/mix.lock index 9d63f4edc..5813403a3 100644 --- a/elixir/mix.lock +++ b/elixir/mix.lock @@ -61,6 +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.4", "3c1bfb6d91bf68f6a3d15f46ae20da0f7740d363ee5bc041191ce8722a6c4fae", [:mix, :rebar3], [{:recon, "~> 2.5.1", [hex: :recon, repo: "hexpm", optional: false]}], "hexpm", "50de6d95d814f447458bd5d72666a74624eddb0ef98bdcee61a0153aae0865ff"}, + "open_api_spex": {:hex, :open_api_spex, "3.20.0", "d4fcf1ee297aa94a673cddb92734eb0bc7cac698be93949a223a50f724e3af89", [:mix], [{: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", "2e9beea71142ff09f8f935579b39406e2c6b5a3978e7235978d7faf2f90cd081"}, "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.0", "03e2177f28dd8d11aaa88e8522c81c2f6a788170fe52f7a65262340961e663f9", [:mix, :rebar3], [{:opentelemetry_semantic_conventions, "~> 0.2", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: false]}], "hexpm", "b9e5ff775fd064fa098dba3c398490b77649a352b40b0b730a6b7dc0bdd68858"}, @@ -112,4 +113,5 @@ "workos": {:git, "https://github.com/firezone/workos-elixir.git", "6d6995e2a765656a6834577837433393fac9b35b", [branch: "main"]}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, "yaml_elixir": {:hex, :yaml_elixir, "2.9.0", "9a256da867b37b8d2c1ffd5d9de373a4fda77a32a45b452f1708508ba7bbcb53", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "0cb0e7d4c56f5e99a6253ed1a670ed0e39c13fc45a6da054033928607ac08dfc"}, + "ymlr": {:hex, :ymlr, "2.0.0", "7525b6da40250777c35456017ef44f7faec06da254eafcf9f9cfb0d65f4c8cb7", [:mix], [], "hexpm", "f9301ad7ea377213b506f6e58ddffd1a7743e24238bb70e572ee510bdc2d1d5a"}, }