This commit is contained in:
@@ -73,3 +73,14 @@ config :phoenix, :stacktrace_depth, 20
|
|||||||
config :phoenix, :plug_init_mode, :runtime
|
config :phoenix, :plug_init_mode, :runtime
|
||||||
|
|
||||||
config :open_api_spex, :cache_adapter, OpenApiSpex.Plug.NoneCache
|
config :open_api_spex, :cache_adapter, OpenApiSpex.Plug.NoneCache
|
||||||
|
|
||||||
|
config :policy_service, :zitadel,
|
||||||
|
issuer: System.get_env("ZITADEL_ISSUER", "https://id.corredorconnect.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"
|
||||||
|
]
|
||||||
|
|||||||
@@ -45,11 +45,11 @@ config :policy_service, :zitadel,
|
|||||||
issuer: System.get_env("ZITADEL_ISSUER", "https://id.corredorconnect.com"),
|
issuer: System.get_env("ZITADEL_ISSUER", "https://id.corredorconnect.com"),
|
||||||
client_id: System.get_env("ZITADEL_CLIENT_ID"),
|
client_id: System.get_env("ZITADEL_CLIENT_ID"),
|
||||||
client_secret: System.get_env("ZITADEL_CLIENT_SECRET"),
|
client_secret: System.get_env("ZITADEL_CLIENT_SECRET"),
|
||||||
|
roles_claim: "urn:zitadel:iam:org:project:#{System.get_env("ZITADEL_PROJECT_ID")}:roles",
|
||||||
required_scopes: [
|
required_scopes: [
|
||||||
"openid",
|
"openid",
|
||||||
"profile",
|
"profile",
|
||||||
"urn:zitadel:iam:user:resourceowner",
|
"urn:zitadel:iam:org:project:#{System.get_env("ZITADEL_PROJECT_ID")}:roles"
|
||||||
"urn:zitadel:iam:org:projects:roles"
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# ## Using releases
|
# ## Using releases
|
||||||
|
|||||||
@@ -48,6 +48,7 @@
|
|||||||
elixir-ls
|
elixir-ls
|
||||||
kubernetes-helm
|
kubernetes-helm
|
||||||
git
|
git
|
||||||
|
nodejs
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ defmodule PolicyService.Application do
|
|||||||
# for more information on OTP Applications
|
# for more information on OTP Applications
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
|
@zitadel Application.fetch_env!(:policy_service, :zitadel)
|
||||||
|
|
||||||
use Application
|
use Application
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def start(_type, _args) do
|
def start(_type, _args) do
|
||||||
zitadel_config = Application.get_env(:policy_service, :zitadel, [])
|
|
||||||
|
|
||||||
children = [
|
children = [
|
||||||
PolicyService.CommandedApp,
|
PolicyService.CommandedApp,
|
||||||
PolicyService.Handlers.QuoteRequestHandler,
|
PolicyService.Handlers.QuoteRequestHandler,
|
||||||
@@ -22,7 +22,7 @@ defmodule PolicyService.Application do
|
|||||||
{Phoenix.PubSub, name: PolicyService.PubSub, pool_size: 1},
|
{Phoenix.PubSub, name: PolicyService.PubSub, pool_size: 1},
|
||||||
{Oidcc.ProviderConfiguration.Worker,
|
{Oidcc.ProviderConfiguration.Worker,
|
||||||
%{
|
%{
|
||||||
issuer: Keyword.get(zitadel_config, :issuer),
|
issuer: @zitadel[:issuer],
|
||||||
name: PolicyService.ZitadelProvider
|
name: PolicyService.ZitadelProvider
|
||||||
}},
|
}},
|
||||||
PolicyServiceWeb.Endpoint
|
PolicyServiceWeb.Endpoint
|
||||||
|
|||||||
@@ -1,187 +0,0 @@
|
|||||||
defmodule PolicyServiceWeb.Plugs.AuthenticationPlug do
|
|
||||||
@moduledoc """
|
|
||||||
Authentication plug for validating JWT tokens using Zitadel.
|
|
||||||
|
|
||||||
This plug validates JWT tokens using the oidcc library and extracts
|
|
||||||
user claims for use throughout the application.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import Plug.Conn
|
|
||||||
require Logger
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Initializes the authentication plug.
|
|
||||||
|
|
||||||
## Options
|
|
||||||
- :provider - The OIDCC provider configuration worker name (required)
|
|
||||||
"""
|
|
||||||
def init(opts) do
|
|
||||||
provider = Keyword.fetch!(opts, :provider)
|
|
||||||
|
|
||||||
zitadel_config = Application.get_env(:policy_service, :zitadel, [])
|
|
||||||
|
|
||||||
%{
|
|
||||||
provider: provider,
|
|
||||||
client_id: Keyword.get(zitadel_config, :client_id),
|
|
||||||
client_secret: Keyword.get(zitadel_config, :client_secret),
|
|
||||||
required_scopes: Keyword.get(zitadel_config, :required_scopes, [])
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Authenticates the request by validating the JWT token.
|
|
||||||
|
|
||||||
Extracts the Authorization header, validates the JWT token using
|
|
||||||
Zitadel's JWKS, and assigns the extracted claims to the connection.
|
|
||||||
"""
|
|
||||||
def call(conn, config) do
|
|
||||||
with {:ok, token} <- extract_token(conn),
|
|
||||||
{:ok, validated_token} <- validate_token(token, config),
|
|
||||||
{:ok, claims} <- extract_claims(validated_token) do
|
|
||||||
conn
|
|
||||||
|> assign(:authenticated, true)
|
|
||||||
|> assign(:current_user, claims)
|
|
||||||
|> assign(:user_id, claims.user_id)
|
|
||||||
|> assign(:org_id, claims.org_id)
|
|
||||||
|> assign(:roles, claims.roles)
|
|
||||||
|> assign(:scopes, claims.scopes)
|
|
||||||
else
|
|
||||||
{:error, :missing_token} ->
|
|
||||||
handle_missing_token(conn)
|
|
||||||
|
|
||||||
{:error, :invalid_token} = error ->
|
|
||||||
handle_invalid_token(conn, error)
|
|
||||||
|
|
||||||
{:error, :expired_token} = error ->
|
|
||||||
handle_expired_token(conn, error)
|
|
||||||
|
|
||||||
{:error, :insufficient_scopes} = error ->
|
|
||||||
handle_insufficient_scopes(conn, error)
|
|
||||||
|
|
||||||
{:error, reason} = error ->
|
|
||||||
handle_authentication_error(conn, error, reason)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp extract_token(conn) do
|
|
||||||
case get_req_header(conn, "authorization") do
|
|
||||||
["Bearer " <> token] when token != "" ->
|
|
||||||
{:ok, token}
|
|
||||||
|
|
||||||
["Bearer " <> token] when token == "" ->
|
|
||||||
{:error, :missing_token}
|
|
||||||
|
|
||||||
["bearer " <> token] when token != "" ->
|
|
||||||
{:ok, token}
|
|
||||||
|
|
||||||
[] ->
|
|
||||||
{:error, :missing_token}
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
{:error, :invalid_token}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp validate_token(token, config) do
|
|
||||||
try do
|
|
||||||
case Oidcc.Token.validate_jwt(
|
|
||||||
token,
|
|
||||||
config.provider,
|
|
||||||
%{}
|
|
||||||
) do
|
|
||||||
{:ok, validated_token} ->
|
|
||||||
validate_required_scopes(validated_token, config)
|
|
||||||
|
|
||||||
{:error, :invalid_token} ->
|
|
||||||
Logger.warning("Invalid JWT token provided")
|
|
||||||
{:error, :invalid_token}
|
|
||||||
|
|
||||||
{:error, :expired_token} ->
|
|
||||||
Logger.warning("Expired JWT token provided")
|
|
||||||
{:error, :expired_token}
|
|
||||||
|
|
||||||
{:error, reason} ->
|
|
||||||
Logger.error("Token validation failed: #{inspect(reason)}")
|
|
||||||
{:error, :invalid_token}
|
|
||||||
end
|
|
||||||
rescue
|
|
||||||
error ->
|
|
||||||
Logger.error("Token validation error: #{inspect(error)}")
|
|
||||||
{:error, :invalid_token}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp validate_required_scopes(validated_token, config) do
|
|
||||||
required_scopes = config.required_scopes || []
|
|
||||||
|
|
||||||
if required_scopes == [] do
|
|
||||||
{:ok, validated_token}
|
|
||||||
else
|
|
||||||
token_scopes = PolicyServiceWeb.Plugs.ClaimsExtractor.get_scopes(validated_token.claims)
|
|
||||||
|
|
||||||
if has_all_required_scopes?(token_scopes, required_scopes) do
|
|
||||||
{:ok, validated_token}
|
|
||||||
else
|
|
||||||
missing_scopes = required_scopes -- token_scopes
|
|
||||||
Logger.warning("Token missing required scopes: #{inspect(missing_scopes)}")
|
|
||||||
{:error, :insufficient_scopes}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp has_all_required_scopes?(token_scopes, required_scopes) do
|
|
||||||
Enum.all?(required_scopes, fn scope -> scope in token_scopes end)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp extract_claims(validated_token) do
|
|
||||||
try do
|
|
||||||
claims = PolicyServiceWeb.Plugs.ClaimsExtractor.extract_claims(validated_token)
|
|
||||||
PolicyServiceWeb.Plugs.ClaimsExtractor.validate_claims!(validated_token.claims)
|
|
||||||
{:ok, claims}
|
|
||||||
rescue
|
|
||||||
error ->
|
|
||||||
Logger.error("Failed to extract claims: #{inspect(error)}")
|
|
||||||
{:error, :invalid_claims}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp handle_missing_token(conn) do
|
|
||||||
conn
|
|
||||||
|> put_resp_content_type("application/json")
|
|
||||||
|> send_resp(401, Jason.encode!(%{error: "Missing authentication token"}))
|
|
||||||
|> halt()
|
|
||||||
end
|
|
||||||
|
|
||||||
defp handle_invalid_token(conn, _error) do
|
|
||||||
conn
|
|
||||||
|> put_resp_content_type("application/json")
|
|
||||||
|> send_resp(401, Jason.encode!(%{error: "Invalid authentication token"}))
|
|
||||||
|> halt()
|
|
||||||
end
|
|
||||||
|
|
||||||
defp handle_expired_token(conn, _error) do
|
|
||||||
conn
|
|
||||||
|> put_resp_content_type("application/json")
|
|
||||||
|> send_resp(401, Jason.encode!(%{error: "Authentication token has expired"}))
|
|
||||||
|> halt()
|
|
||||||
end
|
|
||||||
|
|
||||||
defp handle_insufficient_scopes(conn, _error) do
|
|
||||||
conn
|
|
||||||
|> put_resp_content_type("application/json")
|
|
||||||
|> send_resp(
|
|
||||||
403,
|
|
||||||
Jason.encode!(%{error: "Insufficient permissions - required scopes not granted"})
|
|
||||||
)
|
|
||||||
|> halt()
|
|
||||||
end
|
|
||||||
|
|
||||||
defp handle_authentication_error(conn, _error, reason) do
|
|
||||||
Logger.error("Authentication error: #{inspect(reason)}")
|
|
||||||
|
|
||||||
conn
|
|
||||||
|> put_resp_content_type("application/json")
|
|
||||||
|> send_resp(401, Jason.encode!(%{error: "Authentication failed"}))
|
|
||||||
|> halt()
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
defmodule PolicyServiceWeb.Plugs.AuthorizationPlug do
|
|
||||||
@moduledoc """
|
|
||||||
Authorization plug for enforcing role-based access control.
|
|
||||||
|
|
||||||
This plug checks if authenticated users have the required roles
|
|
||||||
and scopes to access protected resources.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import Plug.Conn
|
|
||||||
require Logger
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Initializes the authorization plug.
|
|
||||||
|
|
||||||
## Options
|
|
||||||
- :required_roles - List of roles that can access the resource (optional)
|
|
||||||
- :required_scopes - List of scopes required to access the resource (optional)
|
|
||||||
- :resource_owner_check - Function to check if user owns the resource (optional)
|
|
||||||
"""
|
|
||||||
def init(opts) do
|
|
||||||
required_permission = Keyword.get(opts, :required_permission, nil)
|
|
||||||
required_scopes = Keyword.get(opts, :required_scopes, [])
|
|
||||||
resource_owner_check = Keyword.get(opts, :resource_owner_check, nil)
|
|
||||||
|
|
||||||
%{
|
|
||||||
required_permission: required_permission,
|
|
||||||
required_scopes: required_scopes,
|
|
||||||
resource_owner_check: resource_owner_check
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Authorizes the request by checking roles and scopes.
|
|
||||||
|
|
||||||
Verifies that the authenticated user has the required roles and scopes
|
|
||||||
to access the requested resource.
|
|
||||||
"""
|
|
||||||
def call(conn, config) do
|
|
||||||
user_roles = conn.assigns[:roles] || []
|
|
||||||
user_scopes = conn.assigns[:scopes] || []
|
|
||||||
user_id = conn.assigns[:user_id]
|
|
||||||
org_id = conn.assigns[:org_id]
|
|
||||||
|
|
||||||
with :ok <- check_roles(user_roles, config.required_roles),
|
|
||||||
:ok <- check_scopes(user_scopes, config.required_scopes),
|
|
||||||
:ok <- check_resource_ownership(conn, config.resource_owner_check, user_id, org_id) do
|
|
||||||
conn
|
|
||||||
else
|
|
||||||
{:error, :insufficient_role} ->
|
|
||||||
handle_insufficient_role(conn, config.required_roles)
|
|
||||||
|
|
||||||
{:error, :insufficient_scope} ->
|
|
||||||
handle_insufficient_scope(conn, config.required_scopes)
|
|
||||||
|
|
||||||
{:error, :not_owner} ->
|
|
||||||
handle_not_owner(conn)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp check_roles(_user_roles, required_roles) when required_roles == [] do
|
|
||||||
:ok
|
|
||||||
end
|
|
||||||
|
|
||||||
defp check_roles(user_roles, required_permission) do
|
|
||||||
if has_any_role?(user_roles, required_permission) do
|
|
||||||
:ok
|
|
||||||
else
|
|
||||||
Logger.warning(
|
|
||||||
"User with roles #{inspect(user_roles)} lacks required permission: #{inspect(required_permission)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
{:error, :insufficient_role}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp check_scopes(_user_scopes, required_scopes) when required_scopes == [] do
|
|
||||||
:ok
|
|
||||||
end
|
|
||||||
|
|
||||||
defp check_scopes(user_scopes, required_scopes) do
|
|
||||||
if has_all_scopes?(user_scopes, required_scopes) do
|
|
||||||
:ok
|
|
||||||
else
|
|
||||||
Logger.warning(
|
|
||||||
"User with scopes #{inspect(user_scopes)} lacks required scopes: #{inspect(required_scopes)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
{:error, :insufficient_scope}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp check_resource_ownership(_conn, nil, _user_id, _org_id) do
|
|
||||||
:ok
|
|
||||||
end
|
|
||||||
|
|
||||||
defp check_resource_ownership(conn, check_function, user_id, org_id) do
|
|
||||||
try do
|
|
||||||
if check_function.(conn, user_id, org_id) do
|
|
||||||
:ok
|
|
||||||
else
|
|
||||||
{:error, :not_owner}
|
|
||||||
end
|
|
||||||
rescue
|
|
||||||
error ->
|
|
||||||
Logger.error("Resource ownership check failed: #{inspect(error)}")
|
|
||||||
{:error, :not_owner}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Checks if the user has any of the required roles.
|
|
||||||
|
|
||||||
Supports role hierarchy where admin has all permissions.
|
|
||||||
"""
|
|
||||||
def has_any_role?(user_roles, required_roles) do
|
|
||||||
cond do
|
|
||||||
"admin" in user_roles ->
|
|
||||||
true
|
|
||||||
|
|
||||||
Enum.any?(required_roles, fn role -> role in user_roles end) ->
|
|
||||||
true
|
|
||||||
|
|
||||||
true ->
|
|
||||||
false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Checks if the user has all of the required scopes.
|
|
||||||
"""
|
|
||||||
def has_all_scopes?(user_scopes, required_scopes) do
|
|
||||||
Enum.all?(required_scopes, fn scope -> scope in user_scopes end)
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Checks if the user can access a specific resource based on ownership.
|
|
||||||
|
|
||||||
Admins can access any resource. Other users can only access their own resources.
|
|
||||||
"""
|
|
||||||
def can_access_resource?(user_roles, resource_user_id, current_user_id) do
|
|
||||||
cond do
|
|
||||||
"admin" in user_roles ->
|
|
||||||
true
|
|
||||||
|
|
||||||
resource_user_id == current_user_id ->
|
|
||||||
true
|
|
||||||
|
|
||||||
true ->
|
|
||||||
false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp handle_insufficient_role(conn, required_roles) do
|
|
||||||
conn
|
|
||||||
|> put_resp_content_type("application/json")
|
|
||||||
|> send_resp(
|
|
||||||
403,
|
|
||||||
Jason.encode!(%{
|
|
||||||
error: "Insufficient permissions",
|
|
||||||
required_roles: required_roles
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|> halt()
|
|
||||||
end
|
|
||||||
|
|
||||||
defp handle_insufficient_scope(conn, required_scopes) do
|
|
||||||
conn
|
|
||||||
|> put_resp_content_type("application/json")
|
|
||||||
|> send_resp(
|
|
||||||
403,
|
|
||||||
Jason.encode!(%{
|
|
||||||
error: "Insufficient permissions",
|
|
||||||
required_scopes: required_scopes
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|> halt()
|
|
||||||
end
|
|
||||||
|
|
||||||
defp handle_not_owner(conn) do
|
|
||||||
conn
|
|
||||||
|> put_resp_content_type("application/json")
|
|
||||||
|> send_resp(
|
|
||||||
403,
|
|
||||||
Jason.encode!(%{error: "You do not have permission to access this resource"})
|
|
||||||
)
|
|
||||||
|> halt()
|
|
||||||
end
|
|
||||||
end
|
|
||||||
88
lib/policy_service_web/plugs/authorize_roles.ex
Normal file
88
lib/policy_service_web/plugs/authorize_roles.ex
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
defmodule PolicyServiceWeb.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
|
||||||
|
IO.inspect(conn.private)
|
||||||
|
|
||||||
|
required_permissions =
|
||||||
|
conn.private[Phoenix.Router.Route]
|
||||||
|
|> Map.get(:options, %{})
|
||||||
|
|> Map.get(:required_permissions, [])
|
||||||
|
|
||||||
|
if authorized?(conn, opts.roles_claim, 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[PolicyServiceWeb.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
|
||||||
|
%{claims: %{^roles_claim => %{} = roles_map}} ->
|
||||||
|
role = Map.get(roles_map, roles_claim, %{})
|
||||||
|
role
|
||||||
|
|
||||||
|
%{claims: claims} when is_map(claims) ->
|
||||||
|
Map.get(claims, 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
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
defmodule PolicyServiceWeb.Plugs.ClaimsExtractor do
|
|
||||||
@moduledoc """
|
|
||||||
Extracts and normalizes Zitadel claims from validated JWT tokens.
|
|
||||||
|
|
||||||
This module handles the extraction of organization ID, user ID, roles,
|
|
||||||
and scopes from Zitadel standard claims and normalizes them for use
|
|
||||||
throughout the application.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Extracts and normalizes claims from a validated OIDCC token.
|
|
||||||
|
|
||||||
## Parameters
|
|
||||||
- token: The validated OIDCC token struct
|
|
||||||
|
|
||||||
## Returns
|
|
||||||
A map containing normalized claims:
|
|
||||||
- org_id: Organization ID from Zitadel claims
|
|
||||||
- user_id: User ID from Zitadel claims
|
|
||||||
- roles: List of user roles
|
|
||||||
- scopes: List of granted scopes
|
|
||||||
"""
|
|
||||||
def extract_claims(token) do
|
|
||||||
claims = token.claims
|
|
||||||
|
|
||||||
%{
|
|
||||||
org_id: get_org_id(claims),
|
|
||||||
user_id: get_user_id(claims),
|
|
||||||
roles: get_roles(claims),
|
|
||||||
scopes: get_scopes(claims)
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Extracts organization ID from Zitadel claims.
|
|
||||||
|
|
||||||
Uses the Zitadel-specific claim 'urn:zitadel:iam:user:resourceowner:id'
|
|
||||||
or falls back to the standard 'azp' (authorized party) claim.
|
|
||||||
"""
|
|
||||||
def get_org_id(claims) do
|
|
||||||
claims["urn:zitadel:iam:user:resourceowner:id"] ||
|
|
||||||
claims["azp"] ||
|
|
||||||
"default"
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Extracts user ID from Zitadel claims.
|
|
||||||
|
|
||||||
Uses the standard 'sub' (subject) claim as the primary user identifier.
|
|
||||||
"""
|
|
||||||
def get_user_id(claims) do
|
|
||||||
claims["sub"] ||
|
|
||||||
raise "Missing required user_id claim"
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Extracts roles from Zitadel claims.
|
|
||||||
|
|
||||||
Zitadel provides roles in the 'urn:zitadel:iam:org:project:roles' claim
|
|
||||||
with a nested structure containing role keys and organization context.
|
|
||||||
"""
|
|
||||||
def get_roles(claims) do
|
|
||||||
project_roles = claims["urn:zitadel:iam:org:project:roles"] || []
|
|
||||||
extract_roles_from_project_roles(project_roles)
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Extracts roles from Zitadel's nested project roles structure.
|
|
||||||
|
|
||||||
The structure is typically:
|
|
||||||
[
|
|
||||||
%{"role1" => %{"id" => "org1", "primaryDomain" => "domain1"}},
|
|
||||||
%{"role2" => %{"id" => "org2", "primaryDomain" => "domain2"}}
|
|
||||||
]
|
|
||||||
"""
|
|
||||||
def extract_roles_from_project_roles(project_roles) when is_list(project_roles) do
|
|
||||||
project_roles
|
|
||||||
|> Enum.flat_map(fn role_map ->
|
|
||||||
role_map
|
|
||||||
|> Map.keys()
|
|
||||||
|> Enum.reject(&(&1 == "id" || &1 == "primaryDomain"))
|
|
||||||
end)
|
|
||||||
|> Enum.map(&String.downcase/1)
|
|
||||||
|> Enum.uniq()
|
|
||||||
end
|
|
||||||
|
|
||||||
def extract_roles_from_project_roles(_), do: []
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Extracts scopes from Zitadel claims.
|
|
||||||
|
|
||||||
Uses the standard 'scope' claim and splits it into a list.
|
|
||||||
"""
|
|
||||||
def get_scopes(claims) do
|
|
||||||
scope_string = claims["scope"] || ""
|
|
||||||
|
|
||||||
scope_string
|
|
||||||
|> String.split(" ")
|
|
||||||
|> Enum.reject(&(&1 == ""))
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Checks if the given claims contain a specific role.
|
|
||||||
"""
|
|
||||||
def has_role?(claims, role) when is_binary(role) do
|
|
||||||
roles = get_roles(claims)
|
|
||||||
role_lower = String.downcase(role)
|
|
||||||
role_lower in roles
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Checks if the given claims contain a specific scope.
|
|
||||||
"""
|
|
||||||
def has_scope?(claims, scope) when is_binary(scope) do
|
|
||||||
scopes = get_scopes(claims)
|
|
||||||
scope in scopes
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Validates that all required claims are present.
|
|
||||||
"""
|
|
||||||
def validate_claims!(claims) do
|
|
||||||
_user_id = get_user_id(claims)
|
|
||||||
_org_id = get_org_id(claims)
|
|
||||||
:ok
|
|
||||||
end
|
|
||||||
end
|
|
||||||
22
lib/policy_service_web/plugs/extract_organization_id.ex
Normal file
22
lib/policy_service_web/plugs/extract_organization_id.ex
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
defmodule PolicyServiceWeb.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/policy_service_web/plugs/require_organization_id.ex
Normal file
27
lib/policy_service_web/plugs/require_organization_id.ex
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
defmodule PolicyServiceWeb.Plugs.RequireOrganizationId do
|
||||||
|
@moduledoc """
|
||||||
|
Ensure `X-Organization-Id` header is provided.
|
||||||
|
|
||||||
|
This plug must be used after `PolicyServiceWeb.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
|
||||||
@@ -4,17 +4,26 @@ defmodule PolicyServiceWeb.Router do
|
|||||||
alias PolicyServiceWeb.PolicyController
|
alias PolicyServiceWeb.PolicyController
|
||||||
alias PolicyServiceWeb.HealthController
|
alias PolicyServiceWeb.HealthController
|
||||||
|
|
||||||
|
@zitadel Application.fetch_env!(:policy_service, :zitadel)
|
||||||
|
|
||||||
pipeline :api do
|
pipeline :api do
|
||||||
plug OpenApiSpex.Plug.PutApiSpec, module: PolicyServiceWeb.ApiSpec
|
plug OpenApiSpex.Plug.PutApiSpec, module: PolicyServiceWeb.ApiSpec
|
||||||
end
|
end
|
||||||
|
|
||||||
pipeline :authenticated do
|
pipeline :authorize do
|
||||||
plug PolicyServiceWeb.Plugs.AuthenticationPlug,
|
plug Oidcc.Plug.ExtractAuthorization
|
||||||
provider: PolicyService.ZitadelProvider
|
plug Oidcc.Plug.RequireAuthorization
|
||||||
end
|
|
||||||
|
|
||||||
pipeline :authorized do
|
plug PolicyServiceWeb.Plugs.RequireOrganizationId
|
||||||
plug PolicyServiceWeb.Plugs.AuthorizationPlug
|
plug PolicyServiceWeb.Plugs.ExtractOrganizationId
|
||||||
|
|
||||||
|
plug Oidcc.Plug.IntrospectToken,
|
||||||
|
provider: PolicyService.ZitadelProvider,
|
||||||
|
client_id: @zitadel[:client_id],
|
||||||
|
client_secret: @zitadel[:client_secret]
|
||||||
|
|
||||||
|
plug PolicyServiceWeb.Plugs.AuthorizeRoles,
|
||||||
|
roles_claim: @zitadel[:roles_claim]
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/health", HealthController, :health
|
get "/health", HealthController, :health
|
||||||
@@ -26,14 +35,17 @@ defmodule PolicyServiceWeb.Router do
|
|||||||
get "/openapi", OpenApiSpex.Plug.RenderSpec, []
|
get "/openapi", OpenApiSpex.Plug.RenderSpec, []
|
||||||
|
|
||||||
scope "/v1" do
|
scope "/v1" do
|
||||||
pipe_through [:authenticated, :authorized]
|
pipe_through [:authorize]
|
||||||
|
|
||||||
get "/policies", PolicyController, :index, required_permission: "policy:read"
|
get "/policies", PolicyController, :index, required_permission: ["policy:read"]
|
||||||
get "/policies/:application_id", PolicyController, :show, required_permission: "policy:read"
|
|
||||||
post "/policies", PolicyController, :create, required_permission: "policy:create_request"
|
get "/policies/:application_id", PolicyController, :show,
|
||||||
|
required_permissions: ["policy:read"]
|
||||||
|
|
||||||
|
post "/policies", PolicyController, :create, required_permissions: ["policy:create_request"]
|
||||||
|
|
||||||
post "/policies/:application_id/accept", PolicyController, :accept,
|
post "/policies/:application_id/accept", PolicyController, :accept,
|
||||||
required_permission: "policy:submit_solicitation"
|
required_permission: ["policy:submit_solicitation"]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,12 @@ controllers:
|
|||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: '{{ include "bjw-s.common.lib.chart.names.fullname" $ }}-apiapp-client-secret'
|
name: '{{ include "bjw-s.common.lib.chart.names.fullname" $ }}-apiapp-client-secret'
|
||||||
key: clientSecret
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user