refactor auth
Some checks failed
Build and Publish / build-release (push) Failing after 1m49s

This commit is contained in:
2026-05-13 13:04:31 -05:00
parent 07a232c131
commit 20d5e86975
12 changed files with 183 additions and 518 deletions

View File

@@ -3,12 +3,12 @@ defmodule PolicyService.Application do
# for more information on OTP Applications
@moduledoc false
@zitadel Application.fetch_env!(:policy_service, :zitadel)
use Application
@impl true
def start(_type, _args) do
zitadel_config = Application.get_env(:policy_service, :zitadel, [])
children = [
PolicyService.CommandedApp,
PolicyService.Handlers.QuoteRequestHandler,
@@ -22,7 +22,7 @@ defmodule PolicyService.Application do
{Phoenix.PubSub, name: PolicyService.PubSub, pool_size: 1},
{Oidcc.ProviderConfiguration.Worker,
%{
issuer: Keyword.get(zitadel_config, :issuer),
issuer: @zitadel[:issuer],
name: PolicyService.ZitadelProvider
}},
PolicyServiceWeb.Endpoint

View File

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

View File

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

View 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

View File

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

View 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

View 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

View File

@@ -4,17 +4,26 @@ defmodule PolicyServiceWeb.Router do
alias PolicyServiceWeb.PolicyController
alias PolicyServiceWeb.HealthController
@zitadel Application.fetch_env!(:policy_service, :zitadel)
pipeline :api do
plug OpenApiSpex.Plug.PutApiSpec, module: PolicyServiceWeb.ApiSpec
end
pipeline :authenticated do
plug PolicyServiceWeb.Plugs.AuthenticationPlug,
provider: PolicyService.ZitadelProvider
end
pipeline :authorize do
plug Oidcc.Plug.ExtractAuthorization
plug Oidcc.Plug.RequireAuthorization
pipeline :authorized do
plug PolicyServiceWeb.Plugs.AuthorizationPlug
plug PolicyServiceWeb.Plugs.RequireOrganizationId
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
get "/health", HealthController, :health
@@ -26,14 +35,17 @@ defmodule PolicyServiceWeb.Router do
get "/openapi", OpenApiSpex.Plug.RenderSpec, []
scope "/v1" do
pipe_through [:authenticated, :authorized]
pipe_through [:authorize]
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", PolicyController, :index, required_permission: ["policy:read"]
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,
required_permission: "policy:submit_solicitation"
required_permission: ["policy:submit_solicitation"]
end
end