From c81b1673d41c148f8a12a1c27a4585da8701f0dd Mon Sep 17 00:00:00 2001 From: HaimKortovich Date: Fri, 15 May 2026 10:19:57 -0500 Subject: [PATCH] add auth --- config/dev.exs | 12 ++ config/runtime.exs | 14 +- flake.nix | 3 +- lib/workload_service/aggregates/task.ex | 21 +++ lib/workload_service/application.ex | 5 + lib/workload_service/commanded_app.ex | 2 + lib/workload_service/commands/quote_task.ex | 41 ++++-- .../commands/solicitation_task.ex | 43 ++++-- lib/workload_service/events/task.ex | 12 +- .../projectors/task_projector.ex | 10 ++ lib/workload_service/workload/queries.ex | 4 +- .../controllers/task_controller.ex | 131 ++++++++++++++---- .../plugs/authorize_roles.ex | 81 +++++++++++ .../plugs/extract_organization_id.ex | 22 +++ .../plugs/require_organization_id.ex | 27 ++++ lib/workload_service_web/router.ex | 89 ++++++++++-- lib/workload_service_web/schemas/task.ex | 10 +- mix.exs | 2 + mix.lock | 3 + ops/chart/values.yaml | 32 +++++ 20 files changed, 488 insertions(+), 76 deletions(-) create mode 100644 lib/workload_service_web/plugs/authorize_roles.ex create mode 100644 lib/workload_service_web/plugs/extract_organization_id.ex create mode 100644 lib/workload_service_web/plugs/require_organization_id.ex diff --git a/config/dev.exs b/config/dev.exs index fc62b4e..f7d4d3e 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -40,3 +40,15 @@ config :open_api_spex, :cache_adapter, OpenApiSpex.Plug.NoneCache config :workload_service, provider_service_url: "http://localhost:4002", solicitation_service_url: "http://localhost:8081" + +config :workload_service, :zitadel, + issuer: System.get_env("ZITADEL_ISSUER", "https://id.corredorconect.com"), + client_id: System.get_env("ZITADEL_CLIENT_ID"), + client_secret: System.get_env("ZITADEL_CLIENT_SECRET"), + roles_claim: "urn:zitadel:iam:org:project:#{System.get_env("ZITADEL_PROJECT_ID")}:roles", + required_scopes: [ + "openid", + "profile", + "urn:zitadel:iam:org:project:#{System.get_env("ZITADEL_PROJECT_ID")}:roles", + "urn:zitadel:iam:org:project:#{System.get_env("ZITADEL_PROJECT_ID")}:aud" + ] diff --git a/config/runtime.exs b/config/runtime.exs index 554dbf2..4df76b4 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -78,6 +78,18 @@ if config_env() == :prod do schema: "eventstore", pool_size: String.to_integer(System.get_env("EVENTSTORE_POOL_SIZE") || "1") + config :workload_service, :zitadel, + issuer: System.get_env("ZITADEL_ISSUER", "https://id.corredorconect.com"), + client_id: System.get_env("ZITADEL_CLIENT_ID"), + client_secret: System.get_env("ZITADEL_CLIENT_SECRET"), + roles_claim: "urn:zitadel:iam:org:project:#{System.get_env("ZITADEL_PROJECT_ID")}:roles", + required_scopes: [ + "openid", + "profile", + "urn:zitadel:iam:org:project:#{System.get_env("ZITADEL_PROJECT_ID")}:roles", + "urn:zitadel:iam:org:project:#{System.get_env("ZITADEL_PROJECT_ID")}:aud" + ] + secret_key_base = System.get_env("SECRET_KEY_BASE") || raise """ @@ -96,4 +108,4 @@ if config_env() == :prod do port: String.to_integer(System.get_env("PORT", "4000")) ], secret_key_base: secret_key_base -end \ No newline at end of file +end diff --git a/flake.nix b/flake.nix index dc0a322..316eee3 100644 --- a/flake.nix +++ b/flake.nix @@ -21,7 +21,7 @@ mixFodDeps = beamPackages.fetchMixDeps { inherit pname version; src = ./.; - sha256 = "sha256-FCkU33NQWIkQYiiOlYLsPxazR8ocHAzmbuu5Eb3vocE="; + sha256 = "sha256-jGAGD/OQj8UcwPPrFjjMhzRhh3ezRQnsD1nnGvvYw38="; }; package = beamPackages.mixRelease { inherit pname version mixFodDeps; @@ -48,6 +48,7 @@ elixir-ls kubernetes-helm git + nodejs ]; }; } diff --git a/lib/workload_service/aggregates/task.ex b/lib/workload_service/aggregates/task.ex index ec25599..9c1151e 100644 --- a/lib/workload_service/aggregates/task.ex +++ b/lib/workload_service/aggregates/task.ex @@ -44,6 +44,7 @@ defmodule WorkloadService.Aggregates.Task do alias unquote(commands_module).CreateTask alias unquote(commands_module).SubmitResponse + alias unquote(commands_module).RequestApproval alias unquote(commands_module).ApproveSubmission alias unquote(commands_module).CompleteTask @@ -86,6 +87,18 @@ defmodule WorkloadService.Aggregates.Task do end end + @impl Aggregate + def execute(%__MODULE__{status: "draft"}, %RequestApproval{} = cmd) do + %WorkloadService.Events.ApprovalRequested{ + id: cmd.id + } + end + + @impl Aggregate + def execute(%__MODULE__{status: status}, %RequestApproval{}) do + {:error, {:invalid_state, "cannot request approval in state: #{status}"}} + end + @impl Aggregate def execute(%__MODULE__{id: id, status: "draft"}, %ApproveSubmission{}) do %WorkloadService.Events.SubmissionApproved{ @@ -141,6 +154,14 @@ defmodule WorkloadService.Aggregates.Task do } end + @impl Aggregate + def apply(%__MODULE__{} = agg, %WorkloadService.Events.ApprovalRequested{}) do + %{ + agg + | status: "approval_requested" + } + end + @impl Aggregate def apply(%__MODULE__{} = agg, %WorkloadService.Events.TaskCompleted{}) do %{ diff --git a/lib/workload_service/application.ex b/lib/workload_service/application.ex index 705067e..518cc94 100644 --- a/lib/workload_service/application.ex +++ b/lib/workload_service/application.ex @@ -15,6 +15,11 @@ defmodule WorkloadService.Application do WorkloadServiceWeb.Telemetry, {DNSCluster, query: Application.get_env(:workload_service, :dns_cluster_query) || :ignore}, {Phoenix.PubSub, name: WorkloadService.PubSub}, + {Oidcc.ProviderConfiguration.Worker, + %{ + issuer: Application.get_env(:workload_service, :zitadel)[:issuer], + name: WorkloadService.ZitadelProvider + }}, WorkloadServiceWeb.Endpoint ] diff --git a/lib/workload_service/commanded_app.ex b/lib/workload_service/commanded_app.ex index 198d684..76ae1c6 100644 --- a/lib/workload_service/commanded_app.ex +++ b/lib/workload_service/commanded_app.ex @@ -5,6 +5,7 @@ defmodule WorkloadService.Router do [ WorkloadService.Commands.QuoteTask.CreateTask, WorkloadService.Commands.QuoteTask.SubmitResponse, + WorkloadService.Commands.QuoteTask.RequestApproval, WorkloadService.Commands.QuoteTask.ApproveSubmission, WorkloadService.Commands.QuoteTask.CompleteTask ], @@ -16,6 +17,7 @@ defmodule WorkloadService.Router do [ WorkloadService.Commands.SolicitationTask.CreateTask, WorkloadService.Commands.SolicitationTask.SubmitResponse, + WorkloadService.Commands.SolicitationTask.RequestApproval, WorkloadService.Commands.SolicitationTask.ApproveSubmission, WorkloadService.Commands.SolicitationTask.CompleteTask ], diff --git a/lib/workload_service/commands/quote_task.ex b/lib/workload_service/commands/quote_task.ex index 1c3d6f5..f49cfbc 100644 --- a/lib/workload_service/commands/quote_task.ex +++ b/lib/workload_service/commands/quote_task.ex @@ -8,11 +8,11 @@ defmodule WorkloadService.Commands.QuoteTask do Command to create a new quote task. """ @type t :: %__MODULE__{ - id: WorkloadService.Aggregates.TaskId.t(), - application_id: WorkloadService.Aggregates.ApplicationId.t(), - task_info: map(), - attachments: [String.t()] - } + id: WorkloadService.Aggregates.TaskId.t(), + application_id: WorkloadService.Aggregates.ApplicationId.t(), + task_info: map(), + attachments: [String.t()] + } @derive Jason.Encoder defstruct [:id, :application_id, :task_info, :attachments] @@ -23,10 +23,10 @@ defmodule WorkloadService.Commands.QuoteTask do Command to submit response for a quote task. """ @type t :: %__MODULE__{ - id: WorkloadService.Aggregates.TaskId.t(), - submission: map(), - attachments: [String.t()] - } + id: WorkloadService.Aggregates.TaskId.t(), + submission: map(), + attachments: [String.t()] + } @derive Jason.Encoder defstruct [:id, :submission, :attachments] @@ -37,8 +37,21 @@ defmodule WorkloadService.Commands.QuoteTask do Command to approve submission for a quote task. """ @type t :: %__MODULE__{ - id: WorkloadService.Aggregates.TaskId.t() - } + id: WorkloadService.Aggregates.TaskId.t() + } + + @derive Jason.Encoder + defstruct [:id] + end + + defmodule RequestApproval do + @moduledoc """ + Command to request approval for a quote task. + Moves task from 'draft' to 'approval_requested'. + """ + @type t :: %__MODULE__{ + id: WorkloadService.Aggregates.TaskId.t() + } @derive Jason.Encoder defstruct [:id] @@ -49,9 +62,9 @@ defmodule WorkloadService.Commands.QuoteTask do Command to complete a quote task. """ @type t :: %__MODULE__{ - id: WorkloadService.Aggregates.TaskId.t(), - completed_by: String.t() - } + id: WorkloadService.Aggregates.TaskId.t(), + completed_by: String.t() + } @derive Jason.Encoder defstruct [:id, :completed_by] diff --git a/lib/workload_service/commands/solicitation_task.ex b/lib/workload_service/commands/solicitation_task.ex index 76776dd..d49032b 100644 --- a/lib/workload_service/commands/solicitation_task.ex +++ b/lib/workload_service/commands/solicitation_task.ex @@ -8,11 +8,11 @@ defmodule WorkloadService.Commands.SolicitationTask do Command to create a new solicitation task. """ @type t :: %__MODULE__{ - id: WorkloadService.Aggregates.TaskId.t(), - application_id: WorkloadService.Aggregates.ApplicationId.t(), - task_info: map(), - attachments: [String.t()] - } + id: WorkloadService.Aggregates.TaskId.t(), + application_id: WorkloadService.Aggregates.ApplicationId.t(), + task_info: map(), + attachments: [String.t()] + } @derive Jason.Encoder defstruct [:id, :application_id, :task_info, :attachments] @@ -23,10 +23,10 @@ defmodule WorkloadService.Commands.SolicitationTask do Command to submit response for a solicitation task. """ @type t :: %__MODULE__{ - id: WorkloadService.Aggregates.TaskId.t(), - submission: map(), - attachments: [String.t()] - } + id: WorkloadService.Aggregates.TaskId.t(), + submission: map(), + attachments: [String.t()] + } @derive Jason.Encoder defstruct [:id, :submission, :attachments] @@ -37,8 +37,21 @@ defmodule WorkloadService.Commands.SolicitationTask do Command to approve submission for a solicitation task. """ @type t :: %__MODULE__{ - id: WorkloadService.Aggregates.TaskId.t() - } + id: WorkloadService.Aggregates.TaskId.t() + } + + @derive Jason.Encoder + defstruct [:id] + end + + defmodule RequestApproval do + @moduledoc """ + Command to request approval for a solicitation task. + Moves task from 'draft' to 'approval_requested'. + """ + @type t :: %__MODULE__{ + id: WorkloadService.Aggregates.TaskId.t() + } @derive Jason.Encoder defstruct [:id] @@ -49,11 +62,11 @@ defmodule WorkloadService.Commands.SolicitationTask do Command to complete a solicitation task. """ @type t :: %__MODULE__{ - id: WorkloadService.Aggregates.TaskId.t(), - completed_by: String.t() - } + id: WorkloadService.Aggregates.TaskId.t(), + completed_by: String.t() + } @derive Jason.Encoder defstruct [:id, :completed_by] end -end \ No newline at end of file +end diff --git a/lib/workload_service/events/task.ex b/lib/workload_service/events/task.ex index b65d15e..8c5b04f 100644 --- a/lib/workload_service/events/task.ex +++ b/lib/workload_service/events/task.ex @@ -54,6 +54,16 @@ defmodule WorkloadService.Events.SubmissionApproved do defstruct [:id] end +defmodule WorkloadService.Events.ApprovalRequested do + @moduledoc """ + Emitted when a user requests approval for their submission. + This transitions the task from 'draft' to 'approval_requested'. + """ + use WorkloadService.Events + @derive Jason.Encoder + defstruct [:id] +end + defmodule WorkloadService.Events.TaskCompleted do @moduledoc """ Emitted when task is completed and sent to policy-service. @@ -62,4 +72,4 @@ defmodule WorkloadService.Events.TaskCompleted do use WorkloadService.Events @derive Jason.Encoder defstruct [:id, :completed_by] -end \ No newline at end of file +end diff --git a/lib/workload_service/projectors/task_projector.ex b/lib/workload_service/projectors/task_projector.ex index e4b4da2..e4d6a12 100644 --- a/lib/workload_service/projectors/task_projector.ex +++ b/lib/workload_service/projectors/task_projector.ex @@ -45,6 +45,16 @@ defmodule WorkloadService.Projectors.TaskProjector do end) end) + project(%Events.ApprovalRequested{} = e, _meta, fn multi -> + multi + |> Ecto.Multi.run(:fetch, fn repo, _ -> + {:ok, repo.get(Task, to_string(e.id))} + end) + |> Ecto.Multi.update(:task, fn %{fetch: task} -> + Ecto.Changeset.change(task, %{status: "approval_requested"}) + end) + end) + project(%Events.TaskCompleted{} = e, _meta, fn multi -> multi |> Ecto.Multi.run(:fetch, fn repo, _ -> diff --git a/lib/workload_service/workload/queries.ex b/lib/workload_service/workload/queries.ex index b40da92..46d0d28 100644 --- a/lib/workload_service/workload/queries.ex +++ b/lib/workload_service/workload/queries.ex @@ -18,8 +18,8 @@ defmodule WorkloadService.Workload.Queries do |> Flop.validate_and_run(params, for: Task) end - def get_task_by_id(id) do - case Repo.get(Task, id) do + def get_task_by_id(org_id, id) do + case Repo.get_by(Task, id: id, org_id: org_id) do nil -> {:error, :not_found} task -> {:ok, task} end diff --git a/lib/workload_service_web/controllers/task_controller.ex b/lib/workload_service_web/controllers/task_controller.ex index 77b48cb..6d84715 100644 --- a/lib/workload_service_web/controllers/task_controller.ex +++ b/lib/workload_service_web/controllers/task_controller.ex @@ -9,6 +9,7 @@ defmodule WorkloadServiceWeb.TaskController do alias WorkloadServiceWeb.QueryHelpers tags(["Tasks"]) + security([%{"bearerAuth" => []}]) operation(:list, summary: "List tasks", @@ -24,7 +25,9 @@ defmodule WorkloadServiceWeb.TaskController do ) def list(conn, params) do - case Queries.list_tasks(params) do + org_id = conn.private[WorkloadServiceWeb.Plugs.ExtractOrganizationId] + + case Queries.list_tasks_by_org(org_id, params) do {:ok, {tasks, meta}} -> conn |> put_status(:ok) @@ -50,7 +53,9 @@ defmodule WorkloadServiceWeb.TaskController do ) def show(conn, %{"id" => id}) do - case Queries.get_task_by_id(id) do + org_id = conn.private[WorkloadServiceWeb.Plugs.ExtractOrganizationId] + + case Queries.get_task_by_id(org_id, id) do {:error, :not_found} -> conn |> put_status(:not_found) |> json(%{error: "task not found"}) @@ -73,22 +78,23 @@ defmodule WorkloadServiceWeb.TaskController do ) def submit(conn, %{"id" => id} = params) do + org_id = conn.private[WorkloadServiceWeb.Plugs.ExtractOrganizationId] task_type = get_task_type(id) case task_type do "quote" -> - handle_quote_submit(conn, id, params) + handle_quote_submit(conn, id, params, org_id) "solicitation" -> - handle_solicitation_submit(conn, id, params) + handle_solicitation_submit(conn, id, params, org_id) _ -> conn |> put_status(:unprocessable_entity) |> json(%{error: "invalid task type"}) end end - defp handle_quote_submit(conn, id, params) do - case Queries.get_task_by_id(id) do + defp handle_quote_submit(conn, id, params, org_id) do + case Queries.get_task_by_id(org_id, id) do {:error, :not_found} -> conn |> put_status(:not_found) |> json(%{error: "task not found"}) @@ -107,15 +113,15 @@ defmodule WorkloadServiceWeb.TaskController do attachments: params["document_urls"] || [] } - dispatch_and_respond(conn, id, command) + dispatch_and_respond(conn, id, command, org_id) {:ok, _task} -> conn |> put_status(:unprocessable_entity) |> json(%{error: "invalid state for submit"}) end end - defp handle_solicitation_submit(conn, id, params) do - case Queries.get_task_by_id(id) do + defp handle_solicitation_submit(conn, id, params, org_id) do + case Queries.get_task_by_id(org_id, id) do {:error, :not_found} -> conn |> put_status(:not_found) |> json(%{error: "task not found"}) @@ -133,7 +139,7 @@ defmodule WorkloadServiceWeb.TaskController do attachments: params["document_urls"] || [] } - dispatch_and_respond(conn, id, command) + dispatch_and_respond(conn, id, command, org_id) {:ok, _task} -> conn |> put_status(:unprocessable_entity) |> json(%{error: "invalid state for submit"}) @@ -153,29 +159,35 @@ defmodule WorkloadServiceWeb.TaskController do ) def approve(conn, %{"id" => id}) do + org_id = conn.private[WorkloadServiceWeb.Plugs.ExtractOrganizationId] task_type = get_task_type(id) case task_type do "quote" -> - handle_approve(conn, id, WorkloadService.Commands.QuoteTask.ApproveSubmission) + handle_approve(conn, id, WorkloadService.Commands.QuoteTask.ApproveSubmission, org_id) "solicitation" -> - handle_approve(conn, id, WorkloadService.Commands.SolicitationTask.ApproveSubmission) + handle_approve( + conn, + id, + WorkloadService.Commands.SolicitationTask.ApproveSubmission, + org_id + ) _ -> conn |> put_status(:unprocessable_entity) |> json(%{error: "invalid task type"}) end end - defp handle_approve(conn, id, command_module) do - case Queries.get_task_by_id(id) do + defp handle_approve(conn, id, command_module, org_id) do + case Queries.get_task_by_id(org_id, id) do {:error, :not_found} -> conn |> put_status(:not_found) |> json(%{error: "task not found"}) {:ok, %{status: "draft"} = _task} -> task_id = TaskId.parse!(id) command = struct(command_module, id: task_id) - dispatch_and_respond(conn, id, command) + dispatch_and_respond(conn, id, command, org_id) {:ok, _task} -> conn |> put_status(:unprocessable_entity) |> json(%{error: "invalid state for approve"}) @@ -195,20 +207,37 @@ defmodule WorkloadServiceWeb.TaskController do ] ) - def complete(conn, %{"id" => id} = params) do + operation(:request_approval, + summary: "Request approval for task", + parameters: [ + id: [in: :path, type: :string, required: true] + ], + responses: [ + ok: {"Task approved", "application/json", S.TaskDetailResponse}, + not_found: {"Not found", "application/json", S.ErrorResponse}, + unprocessable_entity: {"Error", "application/json", S.ErrorResponse} + ] + ) + + def request_approval(conn, %{"id" => id}) do + org_id = conn.private[WorkloadServiceWeb.Plugs.ExtractOrganizationId] task_type = get_task_type(id) - completed_by = params["completed_by"] || "system" case task_type do "quote" -> - handle_complete(conn, id, completed_by, WorkloadService.Commands.QuoteTask.CompleteTask) - - "solicitation" -> - handle_complete( + handle_request_approval( conn, id, - completed_by, - WorkloadService.Commands.SolicitationTask.CompleteTask + WorkloadService.Commands.QuoteTask.RequestApproval, + org_id + ) + + "solicitation" -> + handle_request_approval( + conn, + id, + WorkloadService.Commands.SolicitationTask.RequestApproval, + org_id ) _ -> @@ -216,25 +245,54 @@ defmodule WorkloadServiceWeb.TaskController do end end - defp handle_complete(conn, id, completed_by, command_module) do - case Queries.get_task_by_id(id) do + def complete(conn, %{"id" => id} = params) do + org_id = conn.private[WorkloadServiceWeb.Plugs.ExtractOrganizationId] + task_type = get_task_type(id) + completed_by = params["completed_by"] || "system" + + case task_type do + "quote" -> + handle_complete( + conn, + id, + completed_by, + WorkloadService.Commands.QuoteTask.CompleteTask, + org_id + ) + + "solicitation" -> + handle_complete( + conn, + id, + completed_by, + WorkloadService.Commands.SolicitationTask.CompleteTask, + org_id + ) + + _ -> + conn |> put_status(:unprocessable_entity) |> json(%{error: "invalid task type"}) + end + end + + defp handle_complete(conn, id, completed_by, command_module, org_id) do + case Queries.get_task_by_id(org_id, id) do {:error, :not_found} -> conn |> put_status(:not_found) |> json(%{error: "task not found"}) {:ok, %{status: "approved"} = _task} -> task_id = TaskId.parse!(id) command = struct(command_module, id: task_id, completed_by: completed_by) - dispatch_and_respond(conn, id, command) + dispatch_and_respond(conn, id, command, org_id) {:ok, _task} -> conn |> put_status(:unprocessable_entity) |> json(%{error: "invalid state for complete"}) end end - defp dispatch_and_respond(conn, id, command) do + defp dispatch_and_respond(conn, id, command, org_id) do case CommandedApp.dispatch(command) do :ok -> - {:ok, task} = Queries.get_task_by_id(id) + {:ok, task} = Queries.get_task_by_id(org_id, id) conn |> put_status(:ok) |> json(%{data: task_detail(task)}) {:error, reason} -> @@ -242,6 +300,23 @@ defmodule WorkloadServiceWeb.TaskController do end end + defp handle_request_approval(conn, id, command_module, org_id) do + case Queries.get_task_by_id(org_id, id) do + {:error, :not_found} -> + conn |> put_status(:not_found) |> json(%{error: "task not found"}) + + {:ok, %{status: "draft"} = _task} -> + task_id = TaskId.parse!(id) + command = struct(command_module, id: task_id) + dispatch_and_respond(conn, id, command, org_id) + + {:ok, _task} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "invalid state for request_approval"}) + end + end + defp get_task_type(id) do case WorkloadService.Aggregates.TaskId.parse(id) do {:ok, %{type: type}} -> type diff --git a/lib/workload_service_web/plugs/authorize_roles.ex b/lib/workload_service_web/plugs/authorize_roles.ex new file mode 100644 index 0000000..79e59e2 --- /dev/null +++ b/lib/workload_service_web/plugs/authorize_roles.ex @@ -0,0 +1,81 @@ +defmodule WorkloadServiceWeb.Plugs.AuthorizeRoles do + @moduledoc """ + Authorize request based on Zitadel role permissions. + + After token introspection, checks if the user holds any of the + `required_permissions` roles for the organization identified by + `X-Organization-Id` header. + + The Zitadel roles claim structure is: + %{"urn:zitadel:iam:org:project::roles": { + "": { + "": "" + }, + "": { + "": "" + } + }} + """ + + @behaviour Plug + + import Plug.Conn + + @impl Plug + def init(opts), + do: + opts + |> Keyword.validate!([ + :roles_claim + ]) + + @impl Plug + def call(conn, opts) do + if authorized?( + conn, + Keyword.get(opts, :roles_claim), + Keyword.get(opts, :required_permissions) + ) do + conn + else + conn + |> put_resp_content_type("application/json") + |> halt() + |> send_resp( + :forbidden, + %{error: "Forbidden", reason: "Missing required role"} + ) + end + end + + defp authorized?(conn, roles_claim, required_permissions) do + org_id = conn.private[WorkloadServiceWeb.Plugs.ExtractOrganizationId] + + with true <- org_id_given?(org_id), + roles_map <- get_roles_map(conn, roles_claim), + true <- has_any_role?(roles_map, org_id, required_permissions) do + true + else + _ -> false + end + end + + defp org_id_given?(org_id), do: not is_nil(org_id) + + defp get_roles_map(conn, roles_claim) do + case conn.private[Oidcc.Plug.IntrospectToken] do + %Oidcc.TokenIntrospection{extra: extra} -> + Map.get(extra, roles_claim, %{}) + + _ -> + %{} + end + end + + defp has_any_role?(roles_map, org_id, required_permissions) do + Enum.any?(required_permissions, fn role -> + role_orgs = Map.get(roles_map, role, %{}) + Map.has_key?(role_orgs, org_id) + end) + end +end diff --git a/lib/workload_service_web/plugs/extract_organization_id.ex b/lib/workload_service_web/plugs/extract_organization_id.ex new file mode 100644 index 0000000..8768320 --- /dev/null +++ b/lib/workload_service_web/plugs/extract_organization_id.ex @@ -0,0 +1,22 @@ +defmodule WorkloadServiceWeb.Plugs.ExtractOrganizationId do + @moduledoc """ + Extract `X-Organization-Id` request header. + + Stores the organization identifier in conn.private[__MODULE__] for downstream authorization checks. + """ + + @behaviour Plug + + import Plug.Conn, only: [get_req_header: 2, put_private: 3] + + @impl Plug + def init(_opts), do: %{} + + @impl Plug + def call(conn, _opts) do + case get_req_header(conn, "x-organization-id") do + [org_id | _rest] -> put_private(conn, __MODULE__, org_id) + [] -> put_private(conn, __MODULE__, nil) + end + end +end diff --git a/lib/workload_service_web/plugs/require_organization_id.ex b/lib/workload_service_web/plugs/require_organization_id.ex new file mode 100644 index 0000000..5481d30 --- /dev/null +++ b/lib/workload_service_web/plugs/require_organization_id.ex @@ -0,0 +1,27 @@ +defmodule WorkloadServiceWeb.Plugs.RequireOrganizationId do + @moduledoc """ + Ensure `X-Organization-Id` header is provided. + + This plug must be used after `WorkloadServiceWeb.Plugs.ExtractOrganizationId`. + """ + + @behaviour Plug + + import Plug.Conn, only: [get_req_header: 2, halt: 1, send_resp: 3] + + @impl Plug + def init(_opts), do: %{} + + @impl Plug + def call(conn, _opts) do + case get_req_header(conn, "x-organization-id") do + [] -> + conn + |> halt() + |> send_resp(:bad_request, "The organization id is required") + + [_org_id] -> + conn + end + end +end diff --git a/lib/workload_service_web/router.ex b/lib/workload_service_web/router.ex index 3eb4844..6ee4966 100644 --- a/lib/workload_service_web/router.ex +++ b/lib/workload_service_web/router.ex @@ -5,29 +5,94 @@ defmodule WorkloadServiceWeb.Router do alias WorkloadServiceWeb.HealthController pipeline :api do - plug OpenApiSpex.Plug.PutApiSpec, module: WorkloadServiceWeb.ApiSpec + plug(OpenApiSpex.Plug.PutApiSpec, module: WorkloadServiceWeb.ApiSpec) end - get "/health", HealthController, :health - get "/health/ready", HealthController, :ready + pipeline :auth do + plug(Oidcc.Plug.ExtractAuthorization) + plug(Oidcc.Plug.RequireAuthorization) + + plug(WorkloadServiceWeb.Plugs.RequireOrganizationId) + plug(WorkloadServiceWeb.Plugs.ExtractOrganizationId) + + plug(:introspect) + end + + pipeline(:read, do: plug(:authorize_roles, required_permissions: ["task:read"])) + pipeline(:submit, do: plug(:authorize_roles, required_permissions: ["task:submit"])) + + pipeline(:request_approval, + do: plug(:authorize_roles, required_permissions: ["task:request_approval"]) + ) + + pipeline(:approve, do: plug(:authorize_roles, required_permissions: ["task:approve"])) + pipeline(:complete, do: plug(:authorize_roles, required_permissions: ["task:complete"])) + + get("/health", HealthController, :health) + get("/health/ready", HealthController, :ready) scope "/api" do - pipe_through [:api] + pipe_through([:api]) - get "/openapi", OpenApiSpex.Plug.RenderSpec, [] + get("/openapi", OpenApiSpex.Plug.RenderSpec, []) scope "/v1" do - get "/tasks", TaskController, :list - get "/tasks/:id", TaskController, :show - post "/tasks/:id/submit", TaskController, :submit - post "/tasks/:id/approve", TaskController, :approve - post "/tasks/:id/complete", TaskController, :complete + pipe_through([:auth]) + + scope "/" do + pipe_through([:read]) + get("/tasks", TaskController, :list) + get("/tasks/:id", TaskController, :show) + end + + scope "/" do + pipe_through([:submit]) + post("/tasks/:id/submit", TaskController, :submit) + end + + scope "/" do + pipe_through([:request_approval]) + post("/tasks/:id/request_approval", TaskController, :request_approval) + end + + scope "/" do + pipe_through([:approve]) + post("/tasks/:id/approve", TaskController, :approve) + end + + scope "/" do + pipe_through([:complete]) + post("/tasks/:id/complete", TaskController, :complete) + end end end if Mix.env() == :dev do scope "/swaggerui" do - get "/", OpenApiSpex.Plug.SwaggerUI, path: "/api/openapi" + get("/", OpenApiSpex.Plug.SwaggerUI, path: "/api/openapi") end end -end \ No newline at end of file + + def introspect(conn, _opts) do + zitadel = Application.get_env(:workload_service, :zitadel) + + opts = + Oidcc.Plug.IntrospectToken.init( + provider: WorkloadService.ZitadelProvider, + client_id: zitadel[:client_id], + client_secret: zitadel[:client_secret], + token_introspection_opts: %{client_self_only: false} + ) + + Oidcc.Plug.IntrospectToken.call(conn, opts) + end + + def authorize_roles(conn, opts) do + zitadel = Application.get_env(:workload_service, :zitadel) + + o = + WorkloadServiceWeb.Plugs.AuthorizeRoles.init(roles_claim: zitadel[:roles_claim]) + + WorkloadServiceWeb.Plugs.AuthorizeRoles.call(conn, Keyword.merge(opts, o)) + end +end diff --git a/lib/workload_service_web/schemas/task.ex b/lib/workload_service_web/schemas/task.ex index 2447f13..df4e81e 100644 --- a/lib/workload_service_web/schemas/task.ex +++ b/lib/workload_service_web/schemas/task.ex @@ -144,7 +144,10 @@ defmodule WorkloadServiceWeb.Schemas.Task do application_id: %Schema{type: :string}, policy_type: %Schema{type: :string, enum: ["car", "life", "fire"]}, task_info: %Schema{oneOf: [QuoteTaskInfo, SolicitationTaskInfo]}, - status: %Schema{type: :string, enum: ["created", "draft", "approved", "completed"]}, + status: %Schema{ + type: :string, + enum: ["created", "draft", "approval_requested", "approved", "completed"] + }, created_at: %Schema{type: :string, format: :"date-time"} } }) @@ -167,7 +170,10 @@ defmodule WorkloadServiceWeb.Schemas.Task do nullable: true }, attachments: %Schema{type: :array, items: %Schema{type: :string}}, - status: %Schema{type: :string, enum: ["created", "draft", "approved", "completed"]}, + status: %Schema{ + type: :string, + enum: ["created", "draft", "approval_requested", "approved", "completed"] + }, created_at: %Schema{type: :string, format: :"date-time"}, updated_at: %Schema{type: :string, format: :"date-time"} } diff --git a/mix.exs b/mix.exs index 5724851..2b842d3 100644 --- a/mix.exs +++ b/mix.exs @@ -60,6 +60,8 @@ defmodule WorkloadService.MixProject do {:uuid, "~> 1.1"}, {:req, "~> 0.5"}, {:cors_plug, "~> 3.0"}, + {:oidcc, "~> 3.7"}, + {:oidcc_plug, "~> 0.4"}, {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false} ] end diff --git a/mix.lock b/mix.lock index ff31d1c..437cbf0 100644 --- a/mix.lock +++ b/mix.lock @@ -23,10 +23,13 @@ "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "oidcc": {:hex, :oidcc, "3.7.2", "2047949832ca7984d6d9c218cc5f23e8096bf50ebb809124d3a01673ee2bfe12", [:mix, :rebar3], [{:igniter, "~> 0.6.3 or ~> 0.7.0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jose, "~> 1.11", [hex: :jose, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_registry, "~> 0.3.1", [hex: :telemetry_registry, repo: "hexpm", optional: false]}], "hexpm", "e3f1ed91509fdeb31ec8b9de4ecda0e80cb68b463a9f5b7a9ee1ee40e521e445"}, + "oidcc_plug": {:hex, :oidcc_plug, "0.4.0", "e31ed82f44c0a1685874f7a8574d3ce714603d398c449b8b0c55e89908623979", [:mix], [{:igniter, "~> 0.5.50 or ~> 0.6.0 or ~> 0.7.0", [hex: :igniter, repo: "hexpm", optional: true]}, {:oidcc, "~> 3.7", [hex: :oidcc, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "4d3d6da5f4b51bd9ffc03e4539c631503d459153e6ba31964316c87f4a310068"}, "open_api_spex": {:hex, :open_api_spex, "3.22.2", "0b3c4f572ee69cb6c936abf426b9d84d8eebd34960871fd77aead746f0d69cb0", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0 or ~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}, {:ymlr, "~> 2.0 or ~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :ymlr, repo: "hexpm", optional: true]}], "hexpm", "0a4fc08472d75e9cfe96e0748c6b1565b3b4398f97bf43fcce41b41b6fd3fb33"}, "phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"}, diff --git a/ops/chart/values.yaml b/ops/chart/values.yaml index 03ac917..a520a52 100644 --- a/ops/chart/values.yaml +++ b/ops/chart/values.yaml @@ -72,6 +72,25 @@ controllers: secretKeyRef: name: '{{ include "bjw-s.common.lib.chart.names.fullname" $ }}-cluster-pg-app' key: uri + + # Zitadel Configuration + ZITADEL_ISSUER: + value: "https://id.corredorconect.com" + ZITADEL_CLIENT_ID: + valueFrom: + secretKeyRef: + name: '{{ include "bjw-s.common.lib.chart.names.fullname" $ }}-apiapp-client-secret' + key: clientId + ZITADEL_CLIENT_SECRET: + valueFrom: + secretKeyRef: + name: '{{ include "bjw-s.common.lib.chart.names.fullname" $ }}-apiapp-client-secret' + key: clientSecret + ZITADEL_PROJECT_ID: + valueFrom: + secretKeyRef: + name: '{{ include "bjw-s.common.lib.chart.names.fullname" $ }}-apiapp-client-secret' + key: projectId probes: liveness: enabled: true @@ -235,3 +254,16 @@ rawResources: schemas: - name: eventstore owner: workload_service + + apiapp: + enabled: true + apiVersion: zitadel.github.com/v1alpha1 + kind: APIApp + suffix: apiapp + spec: + spec: + projectRef: + name: seguros-dev + namespace: zitadel-resources-operator + apiAppName: workload-service + authMethodType: API_AUTH_METHOD_TYPE_BASIC