From 141104822eec8afa63b66b761c5d780e93b6d631 Mon Sep 17 00:00:00 2001 From: HaimKortovich Date: Fri, 15 May 2026 10:21:36 -0500 Subject: [PATCH] add auth --- config/config.exs | 4 + config/runtime.exs | 21 ++- flake.nix | 1 + lib/provider_service/application.ex | 5 + lib/provider_service_web/api_spec.ex | 21 ++- lib/provider_service_web/endpoint.ex | 7 +- .../plugs/authorize_roles.ex | 81 +++++++++++ .../plugs/extract_organization_id.ex | 22 +++ .../plugs/require_organization_id.ex | 27 ++++ lib/provider_service_web/router.ex | 131 ++++++++++++------ mix.exs | 6 +- mix.lock | 3 + ops/chart/values.yaml | 32 +++++ 13 files changed, 314 insertions(+), 47 deletions(-) create mode 100644 lib/provider_service_web/plugs/authorize_roles.ex create mode 100644 lib/provider_service_web/plugs/extract_organization_id.ex create mode 100644 lib/provider_service_web/plugs/require_organization_id.ex diff --git a/config/config.exs b/config/config.exs index 70f0b9c..7b02aec 100644 --- a/config/config.exs +++ b/config/config.exs @@ -29,6 +29,10 @@ config :logger, :default_formatter, # Use Jason for JSON parsing in Phoenix config :phoenix, :json_library, Jason +# CORS +config :cors_plug, + origin: "*" + config :provider_service, ProviderService.CommandedApp, event_store: [ adapter: Commanded.EventStore.Adapters.EventStore, diff --git a/config/runtime.exs b/config/runtime.exs index e897b2c..07da250 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -26,6 +26,25 @@ cors_origin = System.get_env("CORS_ORIGIN", "*") config :cors_plug, origin: cors_origin +# Zitadel OIDC configuration +zitadel_issuer = System.get_env("ZITADEL_ISSUER", "https://id.corredorconect.com") +zitadel_client_id = System.get_env("ZITADEL_CLIENT_ID") +zitadel_client_secret = System.get_env("ZITADEL_CLIENT_SECRET") +zitadel_project_id = System.get_env("ZITADEL_PROJECT_ID") + +config :provider_service, :zitadel, + issuer: zitadel_issuer, + client_id: zitadel_client_id, + client_secret: zitadel_client_secret, + project_id: zitadel_project_id, + roles_claim: "urn:zitadel:iam:org:project:#{zitadel_project_id}:roles", + required_scopes: [ + "openid", + "profile", + "urn:zitadel:iam:org:project:#{zitadel_project_id}:roles", + "urn:zitadel:iam:org:project:#{zitadel_project_id}:aud" + ] + # ## Using releases # # If you use `mix release`, you need to explicitly enable the server @@ -82,4 +101,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 582b73c..8243247 100644 --- a/flake.nix +++ b/flake.nix @@ -48,6 +48,7 @@ elixir-ls kubernetes-helm git + nodejs ]; }; } diff --git a/lib/provider_service/application.ex b/lib/provider_service/application.ex index bbb425c..ff745d2 100644 --- a/lib/provider_service/application.ex +++ b/lib/provider_service/application.ex @@ -6,6 +6,11 @@ defmodule ProviderService.Application do ProviderService.Repo, ProviderService.CommandedApp, ProviderService.Projections.ProviderProjection, + {Oidcc.ProviderConfiguration.Worker, + %{ + issuer: Application.get_env(:provider_service, :zitadel)[:issuer], + name: ProviderService.ZitadelProvider + }}, ProviderServiceWeb.Endpoint ] diff --git a/lib/provider_service_web/api_spec.ex b/lib/provider_service_web/api_spec.ex index 21aae95..aabf5ec 100644 --- a/lib/provider_service_web/api_spec.ex +++ b/lib/provider_service_web/api_spec.ex @@ -1,5 +1,5 @@ defmodule ProviderServiceWeb.ApiSpec do - alias OpenApiSpex.{OpenApi, Info, Server} + alias OpenApiSpex.{OpenApi, Info, Server, Components, SecurityScheme} alias OpenApiSpex.{Info, OpenApi, Paths, Server} alias ProviderServiceWeb.{Endpoint, Router} @@ -17,7 +17,24 @@ defmodule ProviderServiceWeb.ApiSpec do 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" + }, + "x-organization-id" => %SecurityScheme{ + type: "apiKey", + in: "header", + name: "x-organization-id", + description: "Organization identifier" + } + } + }, + security: [%{"bearerAuth" => [], "x-organization-id" => []}] } # Discover request/response schemas from path specs |> OpenApiSpex.resolve_schema_modules() diff --git a/lib/provider_service_web/endpoint.ex b/lib/provider_service_web/endpoint.ex index aec5ab0..1dfed52 100644 --- a/lib/provider_service_web/endpoint.ex +++ b/lib/provider_service_web/endpoint.ex @@ -20,6 +20,11 @@ defmodule ProviderServiceWeb.Endpoint do plug(Plug.MethodOverride) plug(Plug.Head) plug(Plug.Session, @session_options) - plug(CORSPlug) + + plug(CORSPlug, + origin: ["*"], + headers: ["*"] + ) + plug(ProviderServiceWeb.Router) end diff --git a/lib/provider_service_web/plugs/authorize_roles.ex b/lib/provider_service_web/plugs/authorize_roles.ex new file mode 100644 index 0000000..c68bd1e --- /dev/null +++ b/lib/provider_service_web/plugs/authorize_roles.ex @@ -0,0 +1,81 @@ +defmodule ProviderServiceWeb.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[ProviderServiceWeb.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/provider_service_web/plugs/extract_organization_id.ex b/lib/provider_service_web/plugs/extract_organization_id.ex new file mode 100644 index 0000000..ffc9a76 --- /dev/null +++ b/lib/provider_service_web/plugs/extract_organization_id.ex @@ -0,0 +1,22 @@ +defmodule ProviderServiceWeb.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/provider_service_web/plugs/require_organization_id.ex b/lib/provider_service_web/plugs/require_organization_id.ex new file mode 100644 index 0000000..0a1d69b --- /dev/null +++ b/lib/provider_service_web/plugs/require_organization_id.ex @@ -0,0 +1,27 @@ +defmodule ProviderServiceWeb.Plugs.RequireOrganizationId do + @moduledoc """ + Ensure `X-Organization-Id` header is provided. + + This plug must be used after `ProviderServiceWeb.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/provider_service_web/router.ex b/lib/provider_service_web/router.ex index 41a8c75..b887251 100644 --- a/lib/provider_service_web/router.ex +++ b/lib/provider_service_web/router.ex @@ -1,11 +1,30 @@ defmodule ProviderServiceWeb.Router do use Phoenix.Router + import Plug.Conn + + alias ProviderServiceWeb.Plugs pipeline :api do plug(:accepts, ["json"]) plug(OpenApiSpex.Plug.PutApiSpec, module: ProviderServiceWeb.ApiSpec) end + pipeline :auth do + plug(Oidcc.Plug.ExtractAuthorization) + plug(Oidcc.Plug.RequireAuthorization) + plug(ProviderServiceWeb.Plugs.RequireOrganizationId) + plug(ProviderServiceWeb.Plugs.ExtractOrganizationId) + plug(:introspect) + end + + pipeline :read do + plug(:authorize_roles, required_permissions: ["provider:read"]) + end + + pipeline :manage do + plug(:authorize_roles, required_permissions: ["provider:manage"]) + end + get("/health", ProviderServiceWeb.HealthController, :health) get("/health/ready", ProviderServiceWeb.HealthController, :ready) @@ -15,59 +34,87 @@ defmodule ProviderServiceWeb.Router do get("/openapi", OpenApiSpex.Plug.RenderSpec, []) scope "/v1" do - # Providers - get("/providers", ProviderServiceWeb.ProviderController, :index) - post("/providers", ProviderServiceWeb.ProviderController, :create) - get("/providers/:provider_id", ProviderServiceWeb.ProviderController, :show) - put("/providers/:provider_id", ProviderServiceWeb.ProviderController, :update) + pipe_through([:auth]) - post( - "/providers/:provider_id/deactivate", - ProviderServiceWeb.ProviderController, - :deactivate - ) + scope "/" do + pipe_through([:read]) + get("/providers", ProviderServiceWeb.ProviderController, :index) + get("/providers/:provider_id", ProviderServiceWeb.ProviderController, :show) + get("/providers/:provider_id/templates", ProviderServiceWeb.TemplateController, :index) + end - post( - "/providers/:provider_id/reactivate", - ProviderServiceWeb.ProviderController, - :reactivate - ) + scope "/" do + pipe_through([:manage]) + post("/providers", ProviderServiceWeb.ProviderController, :create) + put("/providers/:provider_id", ProviderServiceWeb.ProviderController, :update) - # Templates - get("/providers/:provider_id/templates", ProviderServiceWeb.TemplateController, :index) + post( + "/providers/:provider_id/deactivate", + ProviderServiceWeb.ProviderController, + :deactivate + ) - post( - "/providers/:provider_id/templates", - ProviderServiceWeb.TemplateController, - :upload_template - ) + post( + "/providers/:provider_id/reactivate", + ProviderServiceWeb.ProviderController, + :reactivate + ) - post( - "/providers/:provider_id/templates/:template_id/activate", - ProviderServiceWeb.TemplateController, - :activate - ) + post( + "/providers/:provider_id/templates", + ProviderServiceWeb.TemplateController, + :upload_template + ) - post( - "/providers/:provider_id/templates/:template_id/deactivate", - ProviderServiceWeb.TemplateController, - :deactivate - ) + post( + "/providers/:provider_id/templates/:template_id/activate", + ProviderServiceWeb.TemplateController, + :activate + ) - post( - "/providers/:provider_id/templates/:template_id/set-default", - ProviderServiceWeb.TemplateController, - :set_default - ) + post( + "/providers/:provider_id/templates/:template_id/deactivate", + ProviderServiceWeb.TemplateController, + :deactivate + ) - delete( - "/providers/:provider_id/templates/:template_id", - ProviderServiceWeb.TemplateController, - :remove - ) + post( + "/providers/:provider_id/templates/:template_id/set-default", + ProviderServiceWeb.TemplateController, + :set_default + ) + + delete( + "/providers/:provider_id/templates/:template_id", + ProviderServiceWeb.TemplateController, + :remove + ) + end end end + defp introspect(conn, _opts) do + zitadel = Application.get_env(:provider_service, :zitadel) + + opts = + Oidcc.Plug.IntrospectToken.init( + provider: ProviderService.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 + + defp authorize_roles(conn, opts) do + zitadel = Application.get_env(:provider_service, :zitadel) + + init_opts = Plugs.AuthorizeRoles.init(roles_claim: zitadel[:roles_claim]) + + Plugs.AuthorizeRoles.call(conn, Keyword.merge(opts, init_opts)) + end + if Mix.env() == :dev do scope "/swaggerui" do get("/", OpenApiSpex.Plug.SwaggerUI, path: "/api/openapi") diff --git a/mix.exs b/mix.exs index 9a3d475..1e22ab1 100644 --- a/mix.exs +++ b/mix.exs @@ -46,7 +46,11 @@ defmodule ProviderService.MixProject do {:open_api_spex, "~> 3.21"}, # Pagination - {:flop, "~> 0.26"} + {:flop, "~> 0.26"}, + + # Authentication + {:oidcc, "~> 3.7"}, + {:oidcc_plug, "~> 0.4"} ] end diff --git a/mix.lock b/mix.lock index 207ca50..d02c922 100644 --- a/mix.lock +++ b/mix.lock @@ -21,12 +21,15 @@ "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "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"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, "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"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "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"}, diff --git a/ops/chart/values.yaml b/ops/chart/values.yaml index 29aba4e..35eba17 100644 --- a/ops/chart/values.yaml +++ b/ops/chart/values.yaml @@ -58,6 +58,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 @@ -160,3 +179,16 @@ rawResources: schemas: - name: eventstore owner: provider_service + + apiapp: + enabled: true + apiVersion: zitadel.github.com/v1alpha1 + kind: APIApp + suffix: apiapp + spec: + spec: + projectRef: + name: provider-service-dev + namespace: zitadel-resources-operator + apiAppName: provider-service + authMethodType: API_AUTH_METHOD_TYPE_BASIC