add auth
Some checks failed
Build and Publish / build-release (push) Failing after 5s

This commit is contained in:
2026-05-15 10:21:36 -05:00
parent 3cc9e2764e
commit 141104822e
13 changed files with 314 additions and 47 deletions

View File

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

View File

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

View File

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

View 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

View 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

View 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

View File

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