add auth
Some checks failed
Build and Publish / build-release (push) Has been cancelled

This commit is contained in:
2026-05-15 10:19:57 -05:00
parent a06c5ece5d
commit c81b1673d4
20 changed files with 488 additions and 76 deletions

View File

@@ -40,3 +40,15 @@ config :open_api_spex, :cache_adapter, OpenApiSpex.Plug.NoneCache
config :workload_service, config :workload_service,
provider_service_url: "http://localhost:4002", provider_service_url: "http://localhost:4002",
solicitation_service_url: "http://localhost:8081" 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"
]

View File

@@ -78,6 +78,18 @@ if config_env() == :prod do
schema: "eventstore", schema: "eventstore",
pool_size: String.to_integer(System.get_env("EVENTSTORE_POOL_SIZE") || "1") 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 = secret_key_base =
System.get_env("SECRET_KEY_BASE") || System.get_env("SECRET_KEY_BASE") ||
raise """ raise """

View File

@@ -21,7 +21,7 @@
mixFodDeps = beamPackages.fetchMixDeps { mixFodDeps = beamPackages.fetchMixDeps {
inherit pname version; inherit pname version;
src = ./.; src = ./.;
sha256 = "sha256-FCkU33NQWIkQYiiOlYLsPxazR8ocHAzmbuu5Eb3vocE="; sha256 = "sha256-jGAGD/OQj8UcwPPrFjjMhzRhh3ezRQnsD1nnGvvYw38=";
}; };
package = beamPackages.mixRelease { package = beamPackages.mixRelease {
inherit pname version mixFodDeps; inherit pname version mixFodDeps;
@@ -48,6 +48,7 @@
elixir-ls elixir-ls
kubernetes-helm kubernetes-helm
git git
nodejs
]; ];
}; };
} }

View File

@@ -44,6 +44,7 @@ defmodule WorkloadService.Aggregates.Task do
alias unquote(commands_module).CreateTask alias unquote(commands_module).CreateTask
alias unquote(commands_module).SubmitResponse alias unquote(commands_module).SubmitResponse
alias unquote(commands_module).RequestApproval
alias unquote(commands_module).ApproveSubmission alias unquote(commands_module).ApproveSubmission
alias unquote(commands_module).CompleteTask alias unquote(commands_module).CompleteTask
@@ -86,6 +87,18 @@ defmodule WorkloadService.Aggregates.Task do
end end
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 @impl Aggregate
def execute(%__MODULE__{id: id, status: "draft"}, %ApproveSubmission{}) do def execute(%__MODULE__{id: id, status: "draft"}, %ApproveSubmission{}) do
%WorkloadService.Events.SubmissionApproved{ %WorkloadService.Events.SubmissionApproved{
@@ -141,6 +154,14 @@ defmodule WorkloadService.Aggregates.Task do
} }
end end
@impl Aggregate
def apply(%__MODULE__{} = agg, %WorkloadService.Events.ApprovalRequested{}) do
%{
agg
| status: "approval_requested"
}
end
@impl Aggregate @impl Aggregate
def apply(%__MODULE__{} = agg, %WorkloadService.Events.TaskCompleted{}) do def apply(%__MODULE__{} = agg, %WorkloadService.Events.TaskCompleted{}) do
%{ %{

View File

@@ -15,6 +15,11 @@ defmodule WorkloadService.Application do
WorkloadServiceWeb.Telemetry, WorkloadServiceWeb.Telemetry,
{DNSCluster, query: Application.get_env(:workload_service, :dns_cluster_query) || :ignore}, {DNSCluster, query: Application.get_env(:workload_service, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: WorkloadService.PubSub}, {Phoenix.PubSub, name: WorkloadService.PubSub},
{Oidcc.ProviderConfiguration.Worker,
%{
issuer: Application.get_env(:workload_service, :zitadel)[:issuer],
name: WorkloadService.ZitadelProvider
}},
WorkloadServiceWeb.Endpoint WorkloadServiceWeb.Endpoint
] ]

View File

@@ -5,6 +5,7 @@ defmodule WorkloadService.Router do
[ [
WorkloadService.Commands.QuoteTask.CreateTask, WorkloadService.Commands.QuoteTask.CreateTask,
WorkloadService.Commands.QuoteTask.SubmitResponse, WorkloadService.Commands.QuoteTask.SubmitResponse,
WorkloadService.Commands.QuoteTask.RequestApproval,
WorkloadService.Commands.QuoteTask.ApproveSubmission, WorkloadService.Commands.QuoteTask.ApproveSubmission,
WorkloadService.Commands.QuoteTask.CompleteTask WorkloadService.Commands.QuoteTask.CompleteTask
], ],
@@ -16,6 +17,7 @@ defmodule WorkloadService.Router do
[ [
WorkloadService.Commands.SolicitationTask.CreateTask, WorkloadService.Commands.SolicitationTask.CreateTask,
WorkloadService.Commands.SolicitationTask.SubmitResponse, WorkloadService.Commands.SolicitationTask.SubmitResponse,
WorkloadService.Commands.SolicitationTask.RequestApproval,
WorkloadService.Commands.SolicitationTask.ApproveSubmission, WorkloadService.Commands.SolicitationTask.ApproveSubmission,
WorkloadService.Commands.SolicitationTask.CompleteTask WorkloadService.Commands.SolicitationTask.CompleteTask
], ],

View File

@@ -8,11 +8,11 @@ defmodule WorkloadService.Commands.QuoteTask do
Command to create a new quote task. Command to create a new quote task.
""" """
@type t :: %__MODULE__{ @type t :: %__MODULE__{
id: WorkloadService.Aggregates.TaskId.t(), id: WorkloadService.Aggregates.TaskId.t(),
application_id: WorkloadService.Aggregates.ApplicationId.t(), application_id: WorkloadService.Aggregates.ApplicationId.t(),
task_info: map(), task_info: map(),
attachments: [String.t()] attachments: [String.t()]
} }
@derive Jason.Encoder @derive Jason.Encoder
defstruct [:id, :application_id, :task_info, :attachments] defstruct [:id, :application_id, :task_info, :attachments]
@@ -23,10 +23,10 @@ defmodule WorkloadService.Commands.QuoteTask do
Command to submit response for a quote task. Command to submit response for a quote task.
""" """
@type t :: %__MODULE__{ @type t :: %__MODULE__{
id: WorkloadService.Aggregates.TaskId.t(), id: WorkloadService.Aggregates.TaskId.t(),
submission: map(), submission: map(),
attachments: [String.t()] attachments: [String.t()]
} }
@derive Jason.Encoder @derive Jason.Encoder
defstruct [:id, :submission, :attachments] defstruct [:id, :submission, :attachments]
@@ -37,8 +37,21 @@ defmodule WorkloadService.Commands.QuoteTask do
Command to approve submission for a quote task. Command to approve submission for a quote task.
""" """
@type t :: %__MODULE__{ @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 @derive Jason.Encoder
defstruct [:id] defstruct [:id]
@@ -49,9 +62,9 @@ defmodule WorkloadService.Commands.QuoteTask do
Command to complete a quote task. Command to complete a quote task.
""" """
@type t :: %__MODULE__{ @type t :: %__MODULE__{
id: WorkloadService.Aggregates.TaskId.t(), id: WorkloadService.Aggregates.TaskId.t(),
completed_by: String.t() completed_by: String.t()
} }
@derive Jason.Encoder @derive Jason.Encoder
defstruct [:id, :completed_by] defstruct [:id, :completed_by]

View File

@@ -8,11 +8,11 @@ defmodule WorkloadService.Commands.SolicitationTask do
Command to create a new solicitation task. Command to create a new solicitation task.
""" """
@type t :: %__MODULE__{ @type t :: %__MODULE__{
id: WorkloadService.Aggregates.TaskId.t(), id: WorkloadService.Aggregates.TaskId.t(),
application_id: WorkloadService.Aggregates.ApplicationId.t(), application_id: WorkloadService.Aggregates.ApplicationId.t(),
task_info: map(), task_info: map(),
attachments: [String.t()] attachments: [String.t()]
} }
@derive Jason.Encoder @derive Jason.Encoder
defstruct [:id, :application_id, :task_info, :attachments] defstruct [:id, :application_id, :task_info, :attachments]
@@ -23,10 +23,10 @@ defmodule WorkloadService.Commands.SolicitationTask do
Command to submit response for a solicitation task. Command to submit response for a solicitation task.
""" """
@type t :: %__MODULE__{ @type t :: %__MODULE__{
id: WorkloadService.Aggregates.TaskId.t(), id: WorkloadService.Aggregates.TaskId.t(),
submission: map(), submission: map(),
attachments: [String.t()] attachments: [String.t()]
} }
@derive Jason.Encoder @derive Jason.Encoder
defstruct [:id, :submission, :attachments] defstruct [:id, :submission, :attachments]
@@ -37,8 +37,21 @@ defmodule WorkloadService.Commands.SolicitationTask do
Command to approve submission for a solicitation task. Command to approve submission for a solicitation task.
""" """
@type t :: %__MODULE__{ @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 @derive Jason.Encoder
defstruct [:id] defstruct [:id]
@@ -49,9 +62,9 @@ defmodule WorkloadService.Commands.SolicitationTask do
Command to complete a solicitation task. Command to complete a solicitation task.
""" """
@type t :: %__MODULE__{ @type t :: %__MODULE__{
id: WorkloadService.Aggregates.TaskId.t(), id: WorkloadService.Aggregates.TaskId.t(),
completed_by: String.t() completed_by: String.t()
} }
@derive Jason.Encoder @derive Jason.Encoder
defstruct [:id, :completed_by] defstruct [:id, :completed_by]

View File

@@ -54,6 +54,16 @@ defmodule WorkloadService.Events.SubmissionApproved do
defstruct [:id] defstruct [:id]
end 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 defmodule WorkloadService.Events.TaskCompleted do
@moduledoc """ @moduledoc """
Emitted when task is completed and sent to policy-service. Emitted when task is completed and sent to policy-service.

View File

@@ -45,6 +45,16 @@ defmodule WorkloadService.Projectors.TaskProjector do
end) end)
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 -> project(%Events.TaskCompleted{} = e, _meta, fn multi ->
multi multi
|> Ecto.Multi.run(:fetch, fn repo, _ -> |> Ecto.Multi.run(:fetch, fn repo, _ ->

View File

@@ -18,8 +18,8 @@ defmodule WorkloadService.Workload.Queries do
|> Flop.validate_and_run(params, for: Task) |> Flop.validate_and_run(params, for: Task)
end end
def get_task_by_id(id) do def get_task_by_id(org_id, id) do
case Repo.get(Task, id) do case Repo.get_by(Task, id: id, org_id: org_id) do
nil -> {:error, :not_found} nil -> {:error, :not_found}
task -> {:ok, task} task -> {:ok, task}
end end

View File

@@ -9,6 +9,7 @@ defmodule WorkloadServiceWeb.TaskController do
alias WorkloadServiceWeb.QueryHelpers alias WorkloadServiceWeb.QueryHelpers
tags(["Tasks"]) tags(["Tasks"])
security([%{"bearerAuth" => []}])
operation(:list, operation(:list,
summary: "List tasks", summary: "List tasks",
@@ -24,7 +25,9 @@ defmodule WorkloadServiceWeb.TaskController do
) )
def list(conn, params) 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}} -> {:ok, {tasks, meta}} ->
conn conn
|> put_status(:ok) |> put_status(:ok)
@@ -50,7 +53,9 @@ defmodule WorkloadServiceWeb.TaskController do
) )
def show(conn, %{"id" => id}) 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} -> {:error, :not_found} ->
conn |> put_status(:not_found) |> json(%{error: "task 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 def submit(conn, %{"id" => id} = params) do
org_id = conn.private[WorkloadServiceWeb.Plugs.ExtractOrganizationId]
task_type = get_task_type(id) task_type = get_task_type(id)
case task_type do case task_type do
"quote" -> "quote" ->
handle_quote_submit(conn, id, params) handle_quote_submit(conn, id, params, org_id)
"solicitation" -> "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"}) conn |> put_status(:unprocessable_entity) |> json(%{error: "invalid task type"})
end end
end end
defp handle_quote_submit(conn, id, params) do defp handle_quote_submit(conn, id, params, org_id) do
case Queries.get_task_by_id(id) do case Queries.get_task_by_id(org_id, id) do
{:error, :not_found} -> {:error, :not_found} ->
conn |> put_status(:not_found) |> json(%{error: "task not found"}) conn |> put_status(:not_found) |> json(%{error: "task not found"})
@@ -107,15 +113,15 @@ defmodule WorkloadServiceWeb.TaskController do
attachments: params["document_urls"] || [] attachments: params["document_urls"] || []
} }
dispatch_and_respond(conn, id, command) dispatch_and_respond(conn, id, command, org_id)
{:ok, _task} -> {:ok, _task} ->
conn |> put_status(:unprocessable_entity) |> json(%{error: "invalid state for submit"}) conn |> put_status(:unprocessable_entity) |> json(%{error: "invalid state for submit"})
end end
end end
defp handle_solicitation_submit(conn, id, params) do defp handle_solicitation_submit(conn, id, params, org_id) do
case Queries.get_task_by_id(id) do case Queries.get_task_by_id(org_id, id) do
{:error, :not_found} -> {:error, :not_found} ->
conn |> put_status(:not_found) |> json(%{error: "task not found"}) conn |> put_status(:not_found) |> json(%{error: "task not found"})
@@ -133,7 +139,7 @@ defmodule WorkloadServiceWeb.TaskController do
attachments: params["document_urls"] || [] attachments: params["document_urls"] || []
} }
dispatch_and_respond(conn, id, command) dispatch_and_respond(conn, id, command, org_id)
{:ok, _task} -> {:ok, _task} ->
conn |> put_status(:unprocessable_entity) |> json(%{error: "invalid state for submit"}) 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 def approve(conn, %{"id" => id}) do
org_id = conn.private[WorkloadServiceWeb.Plugs.ExtractOrganizationId]
task_type = get_task_type(id) task_type = get_task_type(id)
case task_type do case task_type do
"quote" -> "quote" ->
handle_approve(conn, id, WorkloadService.Commands.QuoteTask.ApproveSubmission) handle_approve(conn, id, WorkloadService.Commands.QuoteTask.ApproveSubmission, org_id)
"solicitation" -> "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"}) conn |> put_status(:unprocessable_entity) |> json(%{error: "invalid task type"})
end end
end end
defp handle_approve(conn, id, command_module) do defp handle_approve(conn, id, command_module, org_id) do
case Queries.get_task_by_id(id) do case Queries.get_task_by_id(org_id, id) do
{:error, :not_found} -> {:error, :not_found} ->
conn |> put_status(:not_found) |> json(%{error: "task not found"}) conn |> put_status(:not_found) |> json(%{error: "task not found"})
{:ok, %{status: "draft"} = _task} -> {:ok, %{status: "draft"} = _task} ->
task_id = TaskId.parse!(id) task_id = TaskId.parse!(id)
command = struct(command_module, id: task_id) command = struct(command_module, id: task_id)
dispatch_and_respond(conn, id, command) dispatch_and_respond(conn, id, command, org_id)
{:ok, _task} -> {:ok, _task} ->
conn |> put_status(:unprocessable_entity) |> json(%{error: "invalid state for approve"}) 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) task_type = get_task_type(id)
completed_by = params["completed_by"] || "system"
case task_type do case task_type do
"quote" -> "quote" ->
handle_complete(conn, id, completed_by, WorkloadService.Commands.QuoteTask.CompleteTask) handle_request_approval(
"solicitation" ->
handle_complete(
conn, conn,
id, id,
completed_by, WorkloadService.Commands.QuoteTask.RequestApproval,
WorkloadService.Commands.SolicitationTask.CompleteTask org_id
)
"solicitation" ->
handle_request_approval(
conn,
id,
WorkloadService.Commands.SolicitationTask.RequestApproval,
org_id
) )
_ -> _ ->
@@ -216,25 +245,54 @@ defmodule WorkloadServiceWeb.TaskController do
end end
end end
defp handle_complete(conn, id, completed_by, command_module) do def complete(conn, %{"id" => id} = params) do
case Queries.get_task_by_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,
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} -> {:error, :not_found} ->
conn |> put_status(:not_found) |> json(%{error: "task not found"}) conn |> put_status(:not_found) |> json(%{error: "task not found"})
{:ok, %{status: "approved"} = _task} -> {:ok, %{status: "approved"} = _task} ->
task_id = TaskId.parse!(id) task_id = TaskId.parse!(id)
command = struct(command_module, id: task_id, completed_by: completed_by) 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} -> {:ok, _task} ->
conn |> put_status(:unprocessable_entity) |> json(%{error: "invalid state for complete"}) conn |> put_status(:unprocessable_entity) |> json(%{error: "invalid state for complete"})
end end
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 case CommandedApp.dispatch(command) do
:ok -> :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)}) conn |> put_status(:ok) |> json(%{data: task_detail(task)})
{:error, reason} -> {:error, reason} ->
@@ -242,6 +300,23 @@ defmodule WorkloadServiceWeb.TaskController do
end end
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 defp get_task_type(id) do
case WorkloadService.Aggregates.TaskId.parse(id) do case WorkloadService.Aggregates.TaskId.parse(id) do
{:ok, %{type: type}} -> type {:ok, %{type: type}} -> type

View File

@@ -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:<project_id>:roles": {
"<role>": {
"<org_id>": "<org_domain>"
},
"<role>": {
"<org_id>": "<org_domain>"
}
}}
"""
@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

View File

@@ -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

View File

@@ -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

View File

@@ -5,29 +5,94 @@ defmodule WorkloadServiceWeb.Router do
alias WorkloadServiceWeb.HealthController alias WorkloadServiceWeb.HealthController
pipeline :api do pipeline :api do
plug OpenApiSpex.Plug.PutApiSpec, module: WorkloadServiceWeb.ApiSpec plug(OpenApiSpex.Plug.PutApiSpec, module: WorkloadServiceWeb.ApiSpec)
end end
get "/health", HealthController, :health pipeline :auth do
get "/health/ready", HealthController, :ready 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 scope "/api" do
pipe_through [:api] pipe_through([:api])
get "/openapi", OpenApiSpex.Plug.RenderSpec, [] get("/openapi", OpenApiSpex.Plug.RenderSpec, [])
scope "/v1" do scope "/v1" do
get "/tasks", TaskController, :list pipe_through([:auth])
get "/tasks/:id", TaskController, :show
post "/tasks/:id/submit", TaskController, :submit scope "/" do
post "/tasks/:id/approve", TaskController, :approve pipe_through([:read])
post "/tasks/:id/complete", TaskController, :complete 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
end end
if Mix.env() == :dev do if Mix.env() == :dev do
scope "/swaggerui" do scope "/swaggerui" do
get "/", OpenApiSpex.Plug.SwaggerUI, path: "/api/openapi" get("/", OpenApiSpex.Plug.SwaggerUI, path: "/api/openapi")
end end
end end
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 end

View File

@@ -144,7 +144,10 @@ defmodule WorkloadServiceWeb.Schemas.Task do
application_id: %Schema{type: :string}, application_id: %Schema{type: :string},
policy_type: %Schema{type: :string, enum: ["car", "life", "fire"]}, policy_type: %Schema{type: :string, enum: ["car", "life", "fire"]},
task_info: %Schema{oneOf: [QuoteTaskInfo, SolicitationTaskInfo]}, 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"} created_at: %Schema{type: :string, format: :"date-time"}
} }
}) })
@@ -167,7 +170,10 @@ defmodule WorkloadServiceWeb.Schemas.Task do
nullable: true nullable: true
}, },
attachments: %Schema{type: :array, items: %Schema{type: :string}}, 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"}, created_at: %Schema{type: :string, format: :"date-time"},
updated_at: %Schema{type: :string, format: :"date-time"} updated_at: %Schema{type: :string, format: :"date-time"}
} }

View File

@@ -60,6 +60,8 @@ defmodule WorkloadService.MixProject do
{:uuid, "~> 1.1"}, {:uuid, "~> 1.1"},
{:req, "~> 0.5"}, {:req, "~> 0.5"},
{:cors_plug, "~> 3.0"}, {:cors_plug, "~> 3.0"},
{:oidcc, "~> 3.7"},
{:oidcc_plug, "~> 0.4"},
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false} {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}
] ]
end end

View File

@@ -23,10 +23,13 @@
"gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"}, "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "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"}, "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"}, "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"}, "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_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "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"}, "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": {: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"}, "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"},

View File

@@ -72,6 +72,25 @@ controllers:
secretKeyRef: secretKeyRef:
name: '{{ include "bjw-s.common.lib.chart.names.fullname" $ }}-cluster-pg-app' name: '{{ include "bjw-s.common.lib.chart.names.fullname" $ }}-cluster-pg-app'
key: uri 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: probes:
liveness: liveness:
enabled: true enabled: true
@@ -235,3 +254,16 @@ rawResources:
schemas: schemas:
- name: eventstore - name: eventstore
owner: workload_service 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