This commit is contained in:
@@ -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
|
||||
]
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
81
lib/provider_service_web/plugs/authorize_roles.ex
Normal file
81
lib/provider_service_web/plugs/authorize_roles.ex
Normal file
@@ -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:<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[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
|
||||
22
lib/provider_service_web/plugs/extract_organization_id.ex
Normal file
22
lib/provider_service_web/plugs/extract_organization_id.ex
Normal file
@@ -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
|
||||
27
lib/provider_service_web/plugs/require_organization_id.ex
Normal file
27
lib/provider_service_web/plugs/require_organization_id.ex
Normal file
@@ -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
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user