diff --git a/config/dev.exs b/config/dev.exs index df19cf2..9792d33 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -71,3 +71,14 @@ config :phoenix, :stacktrace_depth, 20 config :phoenix, :plug_init_mode, :runtime config :open_api_spex, :cache_adapter, OpenApiSpex.Plug.NoneCache + +config :customer_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" + ] diff --git a/config/runtime.exs b/config/runtime.exs index 3cbdcfd..416bfff 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -66,4 +66,16 @@ if config_env() == :prod do port: String.to_integer(System.get_env("PORT", "8080")) ], secret_key_base: secret_key_base + + config :customer_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" + ] end diff --git a/flake.nix b/flake.nix index 81ea2f0..11ec993 100644 --- a/flake.nix +++ b/flake.nix @@ -21,7 +21,7 @@ mixFodDeps = beamPackages.fetchMixDeps { inherit pname version; src = pkgs.lib.cleanSource ./.; - sha256 = "sha256-SspB/uMURF/QIjs+h11rr+X/pJ4dy7zuX8HV52CK998="; + sha256 = "sha256-/PT9ofpAGcKoRsds1JCXT7nzPgGnZrcu6gSQLJBpRE0="; }; package = beamPackages.mixRelease { inherit pname version mixFodDeps; @@ -33,7 +33,7 @@ }; dockerImage = pkgs.dockerTools.buildLayeredImage { name = "customer_service"; - contents = [ package pkgs.bashInteractive pkgs.busybox pkgs.shadow ]; + contents = [ package pkgs.bashInteractive pkgs.busybox pkgs.shadow pkgs.dockerTools.caCertificates ]; config = { Cmd = [ "${package}/bin/customer_service" ]; }; @@ -48,6 +48,7 @@ elixir-ls kubernetes-helm git + nodejs ]; }; } diff --git a/lib/customer_service/aggregates/customer_id.ex b/lib/customer_service/aggregates/customer_id.ex new file mode 100644 index 0000000..70d6512 --- /dev/null +++ b/lib/customer_service/aggregates/customer_id.ex @@ -0,0 +1,48 @@ +defmodule CustomerService.Aggregates.CustomerId do + @type t :: %__MODULE__{ + org_id: String.t(), + customer_type: String.t(), + customer_id: String.t() + } + @derive {Jason.Encoder, only: [:org_id, :customer_type, :customer_id]} + defstruct [:org_id, :customer_type, :customer_id] + + def new(org_id, customer_type, customer_id) + when is_binary(org_id) and is_binary(customer_type) and is_binary(customer_id) do + %__MODULE__{ + org_id: org_id, + customer_type: customer_type, + customer_id: customer_id + } + end + + def parse(<<_::binary>> = string) do + case String.split(string, ":", parts: 3) do + [org_id, customer_type, customer_id] -> + {:ok, %__MODULE__{org_id: org_id, customer_type: customer_type, customer_id: customer_id}} + + _ -> + {:error, :invalid_customer_id} + end + end + + def parse! do + {:error, :invalid_customer_id} + end + + def to_string(%__MODULE__{ + org_id: org_id, + customer_type: customer_type, + customer_id: customer_id + }) do + org_id <> ":" <> customer_type <> ":" <> customer_id + end + + defimpl Commanded.Serialization.JsonDecoder do + def decode(%{org_id: org_id, customer_type: customer_type, customer_id: customer_id}) do + CustomerService.Aggregates.CustomerId.new(org_id, customer_type, customer_id) + end + + def decode(id), do: id + end +end diff --git a/lib/customer_service/aggregates/lead_id.ex b/lib/customer_service/aggregates/lead_id.ex new file mode 100644 index 0000000..64160db --- /dev/null +++ b/lib/customer_service/aggregates/lead_id.ex @@ -0,0 +1,39 @@ +defmodule CustomerService.Aggregates.LeadId do + @type t :: %__MODULE__{ + org_id: String.t(), + type: String.t(), + lead_id: String.t() + } + @derive {Jason.Encoder, only: [:org_id, :type, :lead_id]} + defstruct [:org_id, :type, :lead_id] + + def new(org_id, lead_id) when is_binary(org_id) and is_binary(lead_id) do + %__MODULE__{ + org_id: org_id, + type: "lead", + lead_id: lead_id + } + end + + def parse(<<_::binary>> = string) do + case String.split(string, ":", parts: 3) do + [org_id, "lead", lead_id] -> + {:ok, %__MODULE__{org_id: org_id, type: "lead", lead_id: lead_id}} + + _ -> + {:error, :invalid_lead_id} + end + end + + def to_string(%__MODULE__{org_id: org_id, type: "lead", lead_id: lead_id}) do + org_id <> ":" <> "lead" <> ":" <> lead_id + end + + defimpl Commanded.Serialization.JsonDecoder do + def decode(%{org_id: org_id, type: "lead", lead_id: lead_id}) do + CustomerService.Aggregates.LeadId.new(org_id, lead_id) + end + + def decode(id), do: id + end +end diff --git a/lib/customer_service/application.ex b/lib/customer_service/application.ex index 92b732b..d263e9f 100644 --- a/lib/customer_service/application.ex +++ b/lib/customer_service/application.ex @@ -7,6 +7,21 @@ defmodule CustomerService.Application do @impl true def start(_type, _args) do + oidcc_child = + case Application.get_env(:customer_service, :zitadel) do + nil -> + [] + + cfg -> + [ + {Oidcc.ProviderConfiguration.Worker, + %{ + issuer: cfg[:issuer], + name: CustomerService.ZitadelProvider + }} + ] + end + children = [ CustomerService.CommandedApp, CustomerService.Repo, @@ -14,21 +29,14 @@ defmodule CustomerService.Application do CustomerService.Projectors.QuickLead, CustomerServiceWeb.Telemetry, {DNSCluster, query: Application.get_env(:customer_service, :dns_cluster_query) || :ignore}, - {Phoenix.PubSub, name: CustomerService.PubSub}, - # Start a worker by calling: CustomerService.Worker.start_link(arg) - # {CustomerService.Worker, arg}, - # Start to serve requests, typically the last entry - CustomerServiceWeb.Endpoint + {Phoenix.PubSub, name: CustomerService.PubSub} + | oidcc_child ++ [CustomerServiceWeb.Endpoint] ] - # See https://hexdocs.pm/elixir/Supervisor.html - # for other strategies and supported options opts = [strategy: :one_for_one, name: CustomerService.Supervisor] Supervisor.start_link(children, opts) end - # Tell Phoenix to update the endpoint configuration - # whenever the application is updated. @impl true def config_change(changed, _new, removed) do CustomerServiceWeb.Endpoint.config_change(changed, removed) diff --git a/lib/customer_service/customer/queries.ex b/lib/customer_service/customer/queries.ex index cb00d37..4d434d2 100644 --- a/lib/customer_service/customer/queries.ex +++ b/lib/customer_service/customer/queries.ex @@ -2,12 +2,13 @@ defmodule CustomerService.Customer.Queries do alias CustomerService.Projections.Customer alias CustomerService.Repo - def list_customers(params \\ %{}) do + def list_by_org(org_id, params \\ %{}) when is_binary(org_id) do + params = Map.put(params, :org_id, org_id) Flop.validate_and_run(Customer, params, for: Customer) end - def get_customer(id) do - case Repo.get(Customer, id) do + def get_by_org(org_id, customer_id) when is_binary(org_id) and is_binary(customer_id) do + case Repo.get_by(Customer, org_id: org_id, customer_id: customer_id) do nil -> {:error, :not_found} customer -> {:ok, customer} end diff --git a/lib/customer_service/events.ex b/lib/customer_service/events.ex index e5cadac..2a26294 100644 --- a/lib/customer_service/events.ex +++ b/lib/customer_service/events.ex @@ -1,4 +1,30 @@ +defmodule CustomerService.Events do + @moduledoc """ + Events macro for adding JsonDecoder to domain events. + """ + + alias CustomerService.Aggregates.CustomerId + alias CustomerService.Aggregates.LeadId + + defmacro __using__(_opts) do + quote do + defimpl Commanded.Serialization.JsonDecoder do + def decode(%{id: %CustomerId{} = id} = event) do + %{event | id: id} + end + + def decode(%{id: %LeadId{} = id} = event) do + %{event | id: id} + end + + def decode(event), do: event + end + end + end +end + defmodule CustomerService.Events.CustomerCreated do + use CustomerService.Events @derive Jason.Encoder defstruct [ :id, @@ -14,6 +40,7 @@ defmodule CustomerService.Events.CustomerCreated do end defmodule CustomerService.Events.CorporateCustomerCreated do + use CustomerService.Events @derive Jason.Encoder defstruct [ :id, @@ -29,6 +56,7 @@ defmodule CustomerService.Events.CorporateCustomerCreated do end defmodule CustomerService.Events.CustomerUpdated do + use CustomerService.Events @derive Jason.Encoder defstruct [ :id, @@ -44,6 +72,7 @@ defmodule CustomerService.Events.CustomerUpdated do end defmodule CustomerService.Events.CorporateCustomerUpdated do + use CustomerService.Events @derive Jason.Encoder defstruct [ :id, @@ -59,6 +88,7 @@ defmodule CustomerService.Events.CorporateCustomerUpdated do end defmodule CustomerService.Events.QuickLeadCreated do + use CustomerService.Events @derive Jason.Encoder defstruct [ :id, @@ -77,6 +107,7 @@ defmodule CustomerService.Events.QuickLeadCreated do end defmodule CustomerService.Events.QuickLeadUpdated do + use CustomerService.Events @derive Jason.Encoder defstruct [ :id, @@ -92,6 +123,7 @@ defmodule CustomerService.Events.QuickLeadUpdated do end defmodule CustomerService.Events.LeadStatusUpdated do + use CustomerService.Events @derive Jason.Encoder defstruct [:id, :status, :previous_status, :updated_at] end diff --git a/lib/customer_service/lead/queries.ex b/lib/customer_service/lead/queries.ex index 1e7236b..314b111 100644 --- a/lib/customer_service/lead/queries.ex +++ b/lib/customer_service/lead/queries.ex @@ -2,6 +2,18 @@ defmodule CustomerService.Lead.Queries do alias CustomerService.Projections.QuickLead alias CustomerService.Repo + def list_by_org(org_id, params \\ %{}) when is_binary(org_id) do + params = Map.put(params, :org_id, org_id) + Flop.validate_and_run(QuickLead, params, for: QuickLead) + end + + def get_by_org(org_id, lead_id) when is_binary(org_id) and is_binary(lead_id) do + case Repo.get_by(QuickLead, org_id: org_id, lead_id: lead_id) do + nil -> {:error, :not_found} + lead -> {:ok, lead} + end + end + def list_leads(params \\ %{}) do Flop.validate_and_run(QuickLead, params, for: QuickLead) end diff --git a/lib/customer_service/projections/customer.ex b/lib/customer_service/projections/customer.ex index e3c7847..4ec2c3c 100644 --- a/lib/customer_service/projections/customer.ex +++ b/lib/customer_service/projections/customer.ex @@ -4,6 +4,8 @@ defmodule CustomerService.Projections.Customer do @derive {Jason.Encoder, only: [ :id, + :org_id, + :customer_id, :customer_type, # individual :first_name, @@ -27,7 +29,7 @@ defmodule CustomerService.Projections.Customer do @derive { Flop.Schema, - filterable: [:customer_type, :email, :phone, :document_id, :ruc, :search], + filterable: [:org_id, :customer_type, :email, :phone, :document_id, :ruc, :search], sortable: [:last_name, :legal_name, :inserted_at], default_limit: 20, max_limit: 100, @@ -40,10 +42,13 @@ defmodule CustomerService.Projections.Customer do ] } - @primary_key {:id, :binary_id, autogenerate: false} + @primary_key {:id, :string, autogenerate: false} @timestamps_opts [type: :utc_datetime_usec] schema "customers" do + field :org_id, :string + field :customer_id, :string + field :customer_type, :string, default: "individual" # individual fields diff --git a/lib/customer_service/projections/quick_lead.ex b/lib/customer_service/projections/quick_lead.ex index 7f519de..6a086f1 100644 --- a/lib/customer_service/projections/quick_lead.ex +++ b/lib/customer_service/projections/quick_lead.ex @@ -4,6 +4,8 @@ defmodule CustomerService.Projections.QuickLead do @derive {Jason.Encoder, only: [ :id, + :org_id, + :lead_id, :name, :email, :phone, @@ -22,7 +24,7 @@ defmodule CustomerService.Projections.QuickLead do @derive { Flop.Schema, - filterable: [:status, :priority, :source, :assigned_to, :search], + filterable: [:org_id, :status, :priority, :source, :assigned_to, :search], sortable: [:name, :company_name, :status, :priority, :inserted_at], default_limit: 20, max_limit: 100, @@ -35,10 +37,13 @@ defmodule CustomerService.Projections.QuickLead do ] } - @primary_key {:id, :binary_id, autogenerate: false} + @primary_key {:id, :string, autogenerate: false} @timestamps_opts [type: :utc_datetime_usec] schema "quick_leads" do + field :org_id, :string + field :lead_id, :string + field :name, :string field :email, :string field :phone, :string diff --git a/lib/customer_service/projectors/customer.ex b/lib/customer_service/projectors/customer.ex index f10aae5..6ab8649 100644 --- a/lib/customer_service/projectors/customer.ex +++ b/lib/customer_service/projectors/customer.ex @@ -7,10 +7,15 @@ defmodule CustomerService.Projectors.Customer do alias CustomerService.Events alias CustomerService.Projections.Customer + alias CustomerService.Aggregates.CustomerId project(%Events.CustomerCreated{} = event, fn multi -> + %CustomerService.Aggregates.CustomerId{org_id: org_id, customer_id: customer_id} = event.id + Ecto.Multi.insert(multi, :customer, %Customer{ - id: event.id, + id: CustomerId.to_string(event.id), + org_id: org_id, + customer_id: customer_id, customer_type: "individual", first_name: event.first_name, last_name: event.last_name, @@ -34,8 +39,12 @@ defmodule CustomerService.Projectors.Customer do end project(%Events.CorporateCustomerCreated{} = e, _meta, fn multi -> + %CustomerService.Aggregates.CustomerId{org_id: org_id, customer_id: customer_id} = e.id + Ecto.Multi.insert(multi, :customer, %Customer{ - id: e.id, + id: CustomerId.to_string(e.id), + org_id: org_id, + customer_id: customer_id, customer_type: "corporate", legal_name: e.legal_name, commercial_name: e.commercial_name, @@ -49,7 +58,9 @@ defmodule CustomerService.Projectors.Customer do end) project(%Events.CustomerUpdated{} = e, _meta, fn multi -> - Ecto.Multi.update_all(multi, :customer, from(c in Customer, where: c.id == ^e.id), + composite_id = CustomerId.to_string(e.id) + + Ecto.Multi.update_all(multi, :customer, from(c in Customer, where: c.id == ^composite_id), set: [ first_name: e.first_name, last_name: e.last_name, @@ -64,7 +75,9 @@ defmodule CustomerService.Projectors.Customer do end) project(%Events.CorporateCustomerUpdated{} = e, _meta, fn multi -> - Ecto.Multi.update_all(multi, :customer, from(c in Customer, where: c.id == ^e.id), + composite_id = CustomerId.to_string(e.id) + + Ecto.Multi.update_all(multi, :customer, from(c in Customer, where: c.id == ^composite_id), set: [ legal_name: e.legal_name, commercial_name: e.commercial_name, diff --git a/lib/customer_service/projectors/quick_lead.ex b/lib/customer_service/projectors/quick_lead.ex index 047f1eb..6d746ed 100644 --- a/lib/customer_service/projectors/quick_lead.ex +++ b/lib/customer_service/projectors/quick_lead.ex @@ -7,10 +7,15 @@ defmodule CustomerService.Projectors.QuickLead do alias CustomerService.Events alias CustomerService.Projections.QuickLead + alias CustomerService.Aggregates.LeadId project(%Events.QuickLeadCreated{} = event, fn multi -> + %CustomerService.Aggregates.LeadId{org_id: org_id, lead_id: lead_id} = event.id + Ecto.Multi.insert(multi, :quick_lead, %QuickLead{ - id: event.id, + id: LeadId.to_string(event.id), + org_id: org_id, + lead_id: lead_id, name: event.name, email: event.email, phone: event.phone, @@ -33,7 +38,9 @@ defmodule CustomerService.Projectors.QuickLead do end) project(%Events.QuickLeadUpdated{} = event, _meta, fn multi -> - Ecto.Multi.update_all(multi, :quick_lead, from(q in QuickLead, where: q.id == ^event.id), + composite_id = LeadId.to_string(event.id) + + Ecto.Multi.update_all(multi, :quick_lead, from(q in QuickLead, where: q.id == ^composite_id), set: [ name: event.name, email: event.email, @@ -48,7 +55,9 @@ defmodule CustomerService.Projectors.QuickLead do end) project(%Events.LeadStatusUpdated{} = event, _meta, fn multi -> - Ecto.Multi.update_all(multi, :quick_lead, from(q in QuickLead, where: q.id == ^event.id), + composite_id = LeadId.to_string(event.id) + + Ecto.Multi.update_all(multi, :quick_lead, from(q in QuickLead, where: q.id == ^composite_id), set: [ status: to_string(event.status) ] diff --git a/lib/customer_service_web/api_spec.ex b/lib/customer_service_web/api_spec.ex index 625f3c3..be22923 100644 --- a/lib/customer_service_web/api_spec.ex +++ b/lib/customer_service_web/api_spec.ex @@ -1,5 +1,5 @@ defmodule CustomerServiceWeb.ApiSpec do - alias OpenApiSpex.{OpenApi, Info, Server} + alias OpenApiSpex.{OpenApi, Info, Server, Components, SecurityScheme} alias OpenApiSpex.{Info, OpenApi, Paths, Server} alias CustomerServiceWeb.{Endpoint, Router} @behaviour OpenApi @@ -8,17 +8,31 @@ defmodule CustomerServiceWeb.ApiSpec do def spec do %OpenApi{ servers: [ - # Populate the Server info from a phoenix endpoint Server.from_endpoint(Endpoint) ], info: %Info{ title: "Customer Service", version: "1.0" }, - # Populate the paths from a phoenix router - paths: Paths.from_router(Router) + paths: Paths.from_router(Router), + components: %Components{ + securitySchemes: %{ + "bearerAuth" => %SecurityScheme{ + type: "http", + scheme: "bearer", + bearerFormat: "JWT", + description: "Zitadel JWT bearer token for authentication" + }, + "x-organization-id" => %SecurityScheme{ + type: "apiKey", + in: "header", + name: "x-organization-id", + description: "Organization identifier for tenant isolation" + } + } + }, + security: [%{"bearerAuth" => [], "x-organization-id" => []}] } - # Discover request/response schemas from path specs |> OpenApiSpex.resolve_schema_modules() end end diff --git a/lib/customer_service_web/controllers/customer.ex b/lib/customer_service_web/controllers/customer.ex index 24f0985..6cf0f2f 100644 --- a/lib/customer_service_web/controllers/customer.ex +++ b/lib/customer_service_web/controllers/customer.ex @@ -12,6 +12,7 @@ defmodule CustomerServiceWeb.CustomerController do alias CustomerService.Customer.Queries, as: CustomerQueries alias CustomerServiceWeb.Schemas.Customer, as: CustomerSchemas alias CustomerServiceWeb.QueryHelpers + alias CustomerService.Aggregates.CustomerId operation(:index, summary: "List customers", @@ -27,7 +28,9 @@ defmodule CustomerServiceWeb.CustomerController do ) def index(conn, params) do - case CustomerQueries.list_customers(params) do + org_id = conn.private[CustomerServiceWeb.Plugs.ExtractOrganizationId] + + case CustomerQueries.list_by_org(org_id, params) do {:ok, {customers, meta}} -> conn |> put_status(:ok) @@ -62,12 +65,17 @@ defmodule CustomerServiceWeb.CustomerController do ) def show(conn, %{"id" => id}) do - case CustomerQueries.get_customer(id) do - {:ok, customer} -> - conn |> put_status(:ok) |> json(%{data: customer_json(customer)}) + org_id = conn.private[CustomerServiceWeb.Plugs.ExtractOrganizationId] + with {:ok, %CustomerId{customer_id: local_id}} <- CustomerId.parse(id), + {:ok, customer} <- CustomerQueries.get_by_org(org_id, local_id) do + conn |> put_status(:ok) |> json(%{data: customer_json(customer)}) + else {:error, :not_found} -> conn |> put_status(:not_found) |> json(%{error: "not found"}) + + :error -> + conn |> put_status(:bad_request) |> json(%{error: "invalid customer id"}) end end @@ -81,7 +89,10 @@ defmodule CustomerServiceWeb.CustomerController do ) def create(conn, params) do - customer_id = Ecto.UUID.generate() + org_id = conn.private[CustomerServiceWeb.Plugs.ExtractOrganizationId] + customer_type = "individual" + customer_uuid = Ecto.UUID.generate() + customer_id = CustomerId.new(org_id, customer_type, customer_uuid) command = %CreateCustomer{ id: customer_id, @@ -108,7 +119,10 @@ defmodule CustomerServiceWeb.CustomerController do ) def create_corporate(conn, params) do - customer_id = Ecto.UUID.generate() + org_id = conn.private[CustomerServiceWeb.Plugs.ExtractOrganizationId] + customer_type = "corporate" + customer_uuid = Ecto.UUID.generate() + customer_id = CustomerId.new(org_id, customer_type, customer_uuid) command = %CreateCorporateCustomer{ id: customer_id, @@ -138,19 +152,30 @@ defmodule CustomerServiceWeb.CustomerController do ) def update(conn, %{"id" => id} = params) do - command = %UpdateCustomer{ - id: id, - first_name: params["first_name"], - last_name: params["last_name"], - birth_date: parse_date(params["birth_date"]), - gender: params["gender"], - email: params["email"], - phone: params["phone"], - document_id: params["document_id"], - address: params["address"] - } + org_id = conn.private[CustomerServiceWeb.Plugs.ExtractOrganizationId] - dispatch_and_return(conn, command, id) + with {:ok, %CustomerId{customer_id: local_id}} <- CustomerId.parse(id), + {:ok, _customer} <- CustomerQueries.get_by_org(org_id, local_id) do + command = %UpdateCustomer{ + id: CustomerId.new(org_id, "individual", local_id), + first_name: params["first_name"], + last_name: params["last_name"], + birth_date: parse_date(params["birth_date"]), + gender: params["gender"], + email: params["email"], + phone: params["phone"], + document_id: params["document_id"], + address: params["address"] + } + + dispatch_and_return(conn, command, CustomerId.new(org_id, "individual", local_id)) + else + {:error, :not_found} -> + conn |> put_status(:not_found) |> json(%{error: "not found"}) + + :error -> + conn |> put_status(:bad_request) |> json(%{error: "invalid customer id"}) + end end operation(:update_corporate, @@ -167,29 +192,55 @@ defmodule CustomerServiceWeb.CustomerController do ) def update_corporate(conn, %{"id" => id} = params) do - command = %UpdateCorporateCustomer{ - id: id, - legal_name: params["legal_name"], - commercial_name: params["commercial_name"], - ruc: params["ruc"], - legal_rep_name: params["legal_rep_name"], - legal_rep_document_id: params["legal_rep_document_id"], - email: params["email"], - phone: params["phone"], - address: params["address"] - } + org_id = conn.private[CustomerServiceWeb.Plugs.ExtractOrganizationId] - dispatch_and_return(conn, command, id) + with {:ok, %CustomerId{customer_id: local_id}} <- CustomerId.parse(id), + {:ok, _customer} <- CustomerQueries.get_by_org(org_id, local_id) do + command = %UpdateCorporateCustomer{ + id: CustomerId.new(org_id, "corporate", local_id), + legal_name: params["legal_name"], + commercial_name: params["commercial_name"], + ruc: params["ruc"], + legal_rep_name: params["legal_rep_name"], + legal_rep_document_id: params["legal_rep_document_id"], + email: params["email"], + phone: params["phone"], + address: params["address"] + } + + dispatch_and_return(conn, command, CustomerId.new(org_id, "corporate", local_id)) + else + {:error, :not_found} -> + conn |> put_status(:not_found) |> json(%{error: "not found"}) + + :error -> + conn |> put_status(:bad_request) |> json(%{error: "invalid customer id"}) + end end - # --------------------------------------------------------------------------- - # Private - # --------------------------------------------------------------------------- - - defp dispatch_and_return(conn, command, customer_id) do + defp dispatch_and_return(conn, command, %CustomerId{} = customer_id) do case CustomerService.CommandedApp.dispatch(command, consistency: :strong) do :ok -> - case CustomerQueries.get_customer(customer_id) do + case CustomerQueries.get_customer(CustomerId.to_string(customer_id)) do + {:ok, customer} -> + conn |> put_status(:ok) |> json(%{data: customer_json(customer)}) + + {:error, :not_found} -> + conn + |> put_status(:internal_server_error) + |> json(%{error: "customer created but not found in projection"}) + end + + {:error, reason} -> + conn |> put_status(:unprocessable_entity) |> json(%{error: inspect(reason)}) + end + end + + defp dispatch_and_return(conn, command, customer_id_string) + when is_binary(customer_id_string) do + case CustomerService.CommandedApp.dispatch(command, consistency: :strong) do + :ok -> + case CustomerQueries.get_customer(customer_id_string) do {:ok, customer} -> conn |> put_status(:ok) |> json(%{data: customer_json(customer)}) diff --git a/lib/customer_service_web/controllers/lead.ex b/lib/customer_service_web/controllers/lead.ex index 75bb89f..f22896a 100644 --- a/lib/customer_service_web/controllers/lead.ex +++ b/lib/customer_service_web/controllers/lead.ex @@ -6,6 +6,7 @@ defmodule CustomerServiceWeb.LeadController do alias CustomerService.Lead.Queries, as: LeadQueries alias CustomerServiceWeb.Schemas.Lead, as: LeadSchemas alias CustomerServiceWeb.QueryHelpers + alias CustomerService.Aggregates.LeadId operation(:index, summary: "List leads", @@ -23,7 +24,9 @@ defmodule CustomerServiceWeb.LeadController do ) def index(conn, params) do - case LeadQueries.list_leads(params) do + org_id = conn.private[CustomerServiceWeb.Plugs.ExtractOrganizationId] + + case LeadQueries.list_by_org(org_id, params) do {:ok, {leads, meta}} -> conn |> put_status(:ok) @@ -58,12 +61,17 @@ defmodule CustomerServiceWeb.LeadController do ) def show(conn, %{"id" => id}) do - case LeadQueries.get_lead(id) do - {:ok, lead} -> - conn |> put_status(:ok) |> json(%{data: lead_json(lead)}) + org_id = conn.private[CustomerServiceWeb.Plugs.ExtractOrganizationId] + with {:ok, %LeadId{lead_id: local_id}} <- LeadId.parse(id), + {:ok, lead} <- LeadQueries.get_by_org(org_id, local_id) do + conn |> put_status(:ok) |> json(%{data: lead_json(lead)}) + else {:error, :not_found} -> conn |> put_status(:not_found) |> json(%{error: "not found"}) + + :error -> + conn |> put_status(:bad_request) |> json(%{error: "invalid lead id"}) end end @@ -76,7 +84,9 @@ defmodule CustomerServiceWeb.LeadController do ) def create(conn, params) do - lead_id = Ecto.UUID.generate() + org_id = conn.private[CustomerServiceWeb.Plugs.ExtractOrganizationId] + lead_uuid = Ecto.UUID.generate() + lead_id = LeadId.new(org_id, lead_uuid) command = %CreateQuickLead{ id: lead_id, @@ -108,19 +118,30 @@ defmodule CustomerServiceWeb.LeadController do ) def update(conn, %{"id" => id} = params) do - command = %UpdateQuickLead{ - id: id, - name: params["name"], - email: params["email"], - phone: params["phone"], - company_name: params["company_name"], - notes: params["notes"], - assigned_to: params["assigned_to"], - estimated_value: parse_decimal(params["estimated_value"]), - expected_close_date: parse_date(params["expected_close_date"]) - } + org_id = conn.private[CustomerServiceWeb.Plugs.ExtractOrganizationId] - dispatch_and_return(conn, command, id) + with {:ok, %LeadId{lead_id: local_id}} <- LeadId.parse(id), + {:ok, _lead} <- LeadQueries.get_by_org(org_id, local_id) do + command = %UpdateQuickLead{ + id: LeadId.new(org_id, local_id), + name: params["name"], + email: params["email"], + phone: params["phone"], + company_name: params["company_name"], + notes: params["notes"], + assigned_to: params["assigned_to"], + estimated_value: parse_decimal(params["estimated_value"]), + expected_close_date: parse_date(params["expected_close_date"]) + } + + dispatch_and_return(conn, command, LeadId.new(org_id, local_id)) + else + {:error, :not_found} -> + conn |> put_status(:not_found) |> json(%{error: "not found"}) + + :error -> + conn |> put_status(:bad_request) |> json(%{error: "invalid lead id"}) + end end operation(:update_status, @@ -136,18 +157,47 @@ defmodule CustomerServiceWeb.LeadController do ) def update_status(conn, %{"id" => id} = params) do - command = %UpdateLeadStatus{ - id: id, - status: String.to_existing_atom(params["status"]) - } + org_id = conn.private[CustomerServiceWeb.Plugs.ExtractOrganizationId] - dispatch_and_return(conn, command, id) + with {:ok, %LeadId{lead_id: local_id}} <- LeadId.parse(id), + {:ok, _lead} <- LeadQueries.get_by_org(org_id, local_id) do + command = %UpdateLeadStatus{ + id: LeadId.new(org_id, local_id), + status: String.to_existing_atom(params["status"]) + } + + dispatch_and_return(conn, command, LeadId.new(org_id, local_id)) + else + {:error, :not_found} -> + conn |> put_status(:not_found) |> json(%{error: "not found"}) + + :error -> + conn |> put_status(:bad_request) |> json(%{error: "invalid lead id"}) + end end - defp dispatch_and_return(conn, command, lead_id) do + defp dispatch_and_return(conn, command, %LeadId{} = lead_id) do case CustomerService.CommandedApp.dispatch(command, consistency: :strong) do :ok -> - case LeadQueries.get_lead(lead_id) do + case LeadQueries.get_lead(LeadId.to_string(lead_id)) do + {:ok, lead} -> + conn |> put_status(:ok) |> json(%{data: lead_json(lead)}) + + {:error, :not_found} -> + conn + |> put_status(:internal_server_error) + |> json(%{error: "lead created but not found in projection"}) + end + + {:error, reason} -> + conn |> put_status(:unprocessable_entity) |> json(%{error: inspect(reason)}) + end + end + + defp dispatch_and_return(conn, command, lead_id_string) when is_binary(lead_id_string) do + case CustomerService.CommandedApp.dispatch(command, consistency: :strong) do + :ok -> + case LeadQueries.get_lead(lead_id_string) do {:ok, lead} -> conn |> put_status(:ok) |> json(%{data: lead_json(lead)}) diff --git a/lib/customer_service_web/endpoint.ex b/lib/customer_service_web/endpoint.ex index b42bd29..8ba4a51 100644 --- a/lib/customer_service_web/endpoint.ex +++ b/lib/customer_service_web/endpoint.ex @@ -45,6 +45,10 @@ defmodule CustomerServiceWeb.Endpoint do plug Plug.MethodOverride plug Plug.Head plug Plug.Session, @session_options - plug CORSPlug + + plug CORSPlug, + origin: ["*"], + headers: ["*"] + plug CustomerServiceWeb.Router end diff --git a/lib/customer_service_web/plugs/authorize_roles.ex b/lib/customer_service_web/plugs/authorize_roles.ex new file mode 100644 index 0000000..32778ee --- /dev/null +++ b/lib/customer_service_web/plugs/authorize_roles.ex @@ -0,0 +1,81 @@ +defmodule CustomerServiceWeb.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, + Jason.encode_to_iodata!(%{error: "Forbidden", reason: "Missing required role"}) + ) + end + end + + defp authorized?(conn, roles_claim, required_permissions) do + org_id = conn.private[CustomerServiceWeb.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/customer_service_web/plugs/extract_organization_id.ex b/lib/customer_service_web/plugs/extract_organization_id.ex new file mode 100644 index 0000000..a53b62f --- /dev/null +++ b/lib/customer_service_web/plugs/extract_organization_id.ex @@ -0,0 +1,22 @@ +defmodule CustomerServiceWeb.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/customer_service_web/plugs/require_organization_id.ex b/lib/customer_service_web/plugs/require_organization_id.ex new file mode 100644 index 0000000..dcf0c12 --- /dev/null +++ b/lib/customer_service_web/plugs/require_organization_id.ex @@ -0,0 +1,27 @@ +defmodule CustomerServiceWeb.Plugs.RequireOrganizationId do + @moduledoc """ + Ensure `X-Organization-Id` header is provided. + + This plug must be used after `CustomerServiceWeb.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/customer_service_web/router.ex b/lib/customer_service_web/router.ex index e7ff29f..b89a3d9 100644 --- a/lib/customer_service_web/router.ex +++ b/lib/customer_service_web/router.ex @@ -1,5 +1,6 @@ defmodule CustomerServiceWeb.Router do use CustomerServiceWeb, :router + alias CustomerServiceWeb.{CustomerController, LeadController} pipeline :api do @@ -7,28 +8,86 @@ defmodule CustomerServiceWeb.Router do plug OpenApiSpex.Plug.PutApiSpec, module: CustomerServiceWeb.ApiSpec end - get("/health", CustomerServiceWeb.HealthController, :health) - get("/health/ready", CustomerServiceWeb.HealthController, :ready) + pipeline :auth do + plug Oidcc.Plug.ExtractAuthorization + plug Oidcc.Plug.RequireAuthorization + + plug CustomerServiceWeb.Plugs.RequireOrganizationId + plug CustomerServiceWeb.Plugs.ExtractOrganizationId + + plug :introspect + end + + pipeline :customer_create do + plug :authorize_roles, required_permissions: ["customer:create"] + end + + pipeline :customer_read do + plug :authorize_roles, required_permissions: ["customer:read"] + end + + pipeline :customer_update do + plug :authorize_roles, required_permissions: ["customer:update"] + end + + pipeline :lead_create do + plug :authorize_roles, required_permissions: ["lead:create"] + end + + pipeline :lead_read do + plug :authorize_roles, required_permissions: ["lead:read"] + end + + pipeline :lead_update do + plug :authorize_roles, required_permissions: ["lead:update"] + end + + get "/health", CustomerServiceWeb.HealthController, :health + get "/health/ready", CustomerServiceWeb.HealthController, :ready scope "/api" do - pipe_through :api + pipe_through [:api] get "/openapi", OpenApiSpex.Plug.RenderSpec, [] scope "/v1" do - post "/customers", CustomerController, :create - post "/customers/individual", CustomerController, :create - post "/customers/corporate", CustomerController, :create_corporate - get "/customers", CustomerController, :index - get "/customers/:id", CustomerController, :show - put "/customers/individual/:id", CustomerController, :update - put "/customers/corporate/:id", CustomerController, :update_corporate + pipe_through [:auth] - post "/leads", LeadController, :create - get "/leads", LeadController, :index - get "/leads/:id", LeadController, :show - put "/leads/:id", LeadController, :update - put "/leads/:id/status", LeadController, :update_status + scope "/" do + pipe_through [:customer_create] + post "/customers", CustomerController, :create + post "/customers/individual", CustomerController, :create + post "/customers/corporate", CustomerController, :create_corporate + end + + scope "/" do + pipe_through [:customer_read] + get "/customers", CustomerController, :index + get "/customers/:id", CustomerController, :show + end + + scope "/" do + pipe_through [:customer_update] + put "/customers/individual/:id", CustomerController, :update + put "/customers/corporate/:id", CustomerController, :update_corporate + end + + scope "/" do + pipe_through [:lead_create] + post "/leads", LeadController, :create + end + + scope "/" do + pipe_through [:lead_read] + get "/leads", LeadController, :index + get "/leads/:id", LeadController, :show + end + + scope "/" do + pipe_through [:lead_update] + put "/leads/:id", LeadController, :update + put "/leads/:id/status", LeadController, :update_status + end end end @@ -37,4 +96,27 @@ defmodule CustomerServiceWeb.Router do get "/", OpenApiSpex.Plug.SwaggerUI, path: "/api/openapi" end end + + def introspect(conn, _opts) do + zitadel = Application.get_env(:customer_service, :zitadel) + + opts = + Oidcc.Plug.IntrospectToken.init( + provider: CustomerService.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(:customer_service, :zitadel) + + o = + CustomerServiceWeb.Plugs.AuthorizeRoles.init(roles_claim: zitadel[:roles_claim]) + + CustomerServiceWeb.Plugs.AuthorizeRoles.call(conn, Keyword.merge(opts, o)) + end end diff --git a/mix.exs b/mix.exs index ccbe257..bb77c82 100644 --- a/mix.exs +++ b/mix.exs @@ -54,7 +54,10 @@ defmodule CustomerService.MixProject do {:open_api_spex, "~> 3.20"}, {:commanded_eventstore_adapter, "~> 1.4"}, {:cors_plug, "~> 3.0"}, - {:flop, "~> 0.26"} + {:flop, "~> 0.26"}, + {:req, "~> 0.5"}, + {:oidcc, "~> 3.7"}, + {:oidcc_plug, "~> 0.4"} ] end diff --git a/mix.lock b/mix.lock index 596b64d..75f4a47 100644 --- a/mix.lock +++ b/mix.lock @@ -11,13 +11,19 @@ "ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"}, "ecto_sql": {:hex, :ecto_sql, "3.13.4", "b6e9d07557ddba62508a9ce4a484989a5bb5e9a048ae0e695f6d93f095c25d60", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2b38cf0749ca4d1c5a8bcbff79bbe15446861ca12a61f9fba604486cb6b62a14"}, "eventstore": {:hex, :eventstore, "1.4.8", "26778c991cfb078f3906a4267060efc7bb5e5943f69ddb8ae6fb60f07042a66e", [:mix], [{:fsm, "~> 0.3", [hex: :fsm, repo: "hexpm", optional: false]}, {:gen_stage, "~> 1.2", [hex: :gen_stage, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.17", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "30c914602fdea8db5992a90ecb1f84068531e764cf0c066be71ff0eec4e3bcb9"}, + "finch": {:hex, :finch, "0.22.0", "5c48fa6f9706a78eb9036cacb67b8b996b4e66d111c543f4c29bb0f879a6806b", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b94e83c47780fc6813f746a1f1a34ee65cda42da4c5ea26a68f0acc4498e23dc"}, "flop": {:hex, :flop, "0.26.3", "9bc700b34f96a57e56aaa89b850926356311372556eacd5a1abe0fdd0ea40bf2", [:mix], [{:ecto, "~> 3.11", [hex: :ecto, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}], "hexpm", "cd77588229778ac55560c90dfbe15ab6486773f067d6e52db9fa703b8c9a9d2d"}, "fsm": {:hex, :fsm, "0.3.1", "087aa9b02779a84320dc7a2d8464452b5308e29877921b2bde81cdba32a12390", [:mix], [], "hexpm", "fbf0d53f89e9082b326b0b5828b94b4c549ff9d1452bbfd00b4d1ac082208e96"}, "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.8.0", "b964eaf4416f2dee2ba88968d52239fca5621b0402b9c95f55a08eb9d74803e9", [: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", "f3c572c11355eccf00f22275e9b42463bc17bd28db13be1e28f8e0bb4adbc849"}, "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.4", "0387f84f00071cba8d71d930b9121b2fb3645197a9206c31b908d2e7902a4851", [: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", "c988b1cd3b084eebb13e6676d572597d387fa607dab258526637b4e6c4c08543"}, "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"}, @@ -26,6 +32,7 @@ "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "postgrex": {:hex, :postgrex, "0.22.0", "fb027b58b6eab1f6de5396a2abcdaaeb168f9ed4eccbb594e6ac393b02078cbd", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a68c4261e299597909e03e6f8ff5a13876f5caadaddd0d23af0d0a61afcc5d84"}, + "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, diff --git a/ops/chart/values.yaml b/ops/chart/values.yaml index 7f65223..ce74bc5 100644 --- a/ops/chart/values.yaml +++ b/ops/chart/values.yaml @@ -63,6 +63,25 @@ controllers: secretKeyRef: name: '{{ include "bjw-s.common.lib.chart.names.fullname" $ }}-secrets' key: cookie + + # 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 @@ -162,6 +181,19 @@ rawResources: owner: customer_service cluster: name: '{{ include "bjw-s.common.lib.chart.names.fullname" $ }}-cluster-pg' - schemas: - - name: eventstore - owner: customer_service + schemas: + - name: eventstore + owner: customer_service + + apiapp: + enabled: true + apiVersion: zitadel.github.com/v1alpha1 + kind: APIApp + suffix: apiapp + spec: + spec: + projectRef: + name: seguros-dev + namespace: zitadel-resources-operator + apiAppName: customer-service + authMethodType: API_AUTH_METHOD_TYPE_BASIC diff --git a/priv/repo/migrations/20260224165602_add_customer_table.exs b/priv/repo/migrations/20260224165602_add_customer_table.exs index f33396e..fc460b3 100644 --- a/priv/repo/migrations/20260224165602_add_customer_table.exs +++ b/priv/repo/migrations/20260224165602_add_customer_table.exs @@ -3,7 +3,9 @@ defmodule CustomerService.Repo.Migrations.AddCustomerTable do def change do create table(:customers, primary_key: false) do - add :id, :uuid, primary_key: true + add :id, :string, primary_key: true + add :org_id, :string, null: false + add :customer_id, :string, null: false add :first_name, :string add :last_name, :string add :birth_date, :date @@ -21,6 +23,7 @@ defmodule CustomerService.Repo.Migrations.AddCustomerTable do timestamps() end + create index(:customers, [:org_id]) create index(:customers, [:email]) end end diff --git a/priv/repo/migrations/20260430210538_add_quick_leads_table.exs b/priv/repo/migrations/20260430210538_add_quick_leads_table.exs index 78087a0..5c92e98 100644 --- a/priv/repo/migrations/20260430210538_add_quick_leads_table.exs +++ b/priv/repo/migrations/20260430210538_add_quick_leads_table.exs @@ -3,7 +3,9 @@ defmodule CustomerService.Repo.Migrations.AddQuickLeadsTable do def change do create table(:quick_leads, primary_key: false) do - add :id, :uuid, primary_key: true + add :id, :string, primary_key: true + add :org_id, :string, null: false + add :lead_id, :string, null: false add :name, :string add :email, :string add :phone, :string @@ -19,6 +21,7 @@ defmodule CustomerService.Repo.Migrations.AddQuickLeadsTable do timestamps() end + create index(:quick_leads, [:org_id]) create index(:quick_leads, [:status]) create index(:quick_leads, [:priority]) create index(:quick_leads, [:source])