diff --git a/README.md b/README.md
index 23a2526..690a4be 100644
--- a/README.md
+++ b/README.md
@@ -101,6 +101,7 @@ Send emails for:
- **Group-based allowlists** - Restrict applications to specific user groups
- **Per-application access** - Each app defines which groups can access it
- **Automatic enforcement** - Access checks during OIDC authorization and ForwardAuth
+- **Custom claims** - Add arbitrary claims to OIDC tokens via groups and users (perfect for app-specific roles)
---
@@ -115,11 +116,13 @@ Send emails for:
- TOTP secret and backup codes (encrypted)
- TOTP enforcement flag
- Status (active, disabled, pending_invitation)
+- Custom claims (JSON) - arbitrary key-value pairs added to OIDC tokens
- Token generation for invitations, password resets, and magic logins
**Group**
- Name (unique, normalized to lowercase)
- Description
+- Custom claims (JSON) - shared claims for all members (merged with user claims)
- Many-to-many with Users and Applications
**Session**
@@ -132,9 +135,11 @@ Send emails for:
**Application**
- Name and slug (URL-safe identifier)
-- Type (oidc, trusted_header, saml)
-- Client ID and secret (for OIDC)
-- Redirect URIs (JSON array)
+- Type (oidc or forward_auth)
+- Client ID and secret (for OIDC apps)
+- Redirect URIs (for OIDC apps)
+- Domain pattern (for ForwardAuth apps, supports wildcards like *.example.com)
+- Headers config (for ForwardAuth apps, JSON configuration for custom header names)
- Metadata (flexible JSON storage)
- Active flag
- Many-to-many with Groups (allowlist)
diff --git a/app/controllers/admin/applications_controller.rb b/app/controllers/admin/applications_controller.rb
index 4bc99de..2243e2c 100644
--- a/app/controllers/admin/applications_controller.rb
+++ b/app/controllers/admin/applications_controller.rb
@@ -1,6 +1,6 @@
module Admin
class ApplicationsController < BaseController
- before_action :set_application, only: [:show, :edit, :update, :destroy, :regenerate_credentials, :roles, :create_role, :update_role, :assign_role, :remove_role]
+ before_action :set_application, only: [:show, :edit, :update, :destroy, :regenerate_credentials]
def index
@applications = Application.order(created_at: :desc)
@@ -90,53 +90,6 @@ module Admin
end
end
- def roles
- @application_roles = @application.application_roles.includes(:user_role_assignments)
- @available_users = User.active.order(:email_address)
- end
-
- def create_role
- @role = @application.application_roles.build(role_params)
-
- if @role.save
- redirect_to roles_admin_application_path(@application), notice: "Role created successfully."
- else
- @application_roles = @application.application_roles.includes(:user_role_assignments)
- @available_users = User.active.order(:email_address)
- render :roles, status: :unprocessable_entity
- end
- end
-
- def update_role
- @role = @application.application_roles.find(params[:role_id])
-
- if @role.update(role_params)
- redirect_to roles_admin_application_path(@application), notice: "Role updated successfully."
- else
- @application_roles = @application.application_roles.includes(:user_role_assignments)
- @available_users = User.active.order(:email_address)
- render :roles, status: :unprocessable_entity
- end
- end
-
- def assign_role
- user = User.find(params[:user_id])
- role = @application.application_roles.find(params[:role_id])
-
- @application.assign_role_to_user!(user, role.name, source: 'manual')
-
- redirect_to roles_admin_application_path(@application), notice: "Role assigned successfully."
- end
-
- def remove_role
- user = User.find(params[:user_id])
- role = @application.application_roles.find(params[:role_id])
-
- @application.remove_role_from_user!(user, role.name)
-
- redirect_to roles_admin_application_path(@application), notice: "Role removed successfully."
- end
-
private
def set_application
@@ -146,12 +99,8 @@ module Admin
def application_params
params.require(:application).permit(
:name, :slug, :app_type, :active, :redirect_uris, :description, :metadata,
- :role_mapping_mode, :role_prefix, :role_claim_name, managed_permissions: {}
+ :domain_pattern, headers_config: {}
)
end
-
- def role_params
- params.require(:application_role).permit(:name, :display_name, :description, :active, permissions: {})
- end
end
end
diff --git a/app/controllers/admin/forward_auth_rules_controller.rb b/app/controllers/admin/forward_auth_rules_controller.rb
deleted file mode 100644
index 17c1b4c..0000000
--- a/app/controllers/admin/forward_auth_rules_controller.rb
+++ /dev/null
@@ -1,84 +0,0 @@
-module Admin
- class ForwardAuthRulesController < BaseController
- before_action :set_forward_auth_rule, only: [:show, :edit, :update, :destroy]
-
- def index
- @forward_auth_rules = ForwardAuthRule.ordered
- end
-
- def show
- @allowed_groups = @forward_auth_rule.allowed_groups
- end
-
- def new
- @forward_auth_rule = ForwardAuthRule.new
- @available_groups = Group.order(:name)
- end
-
- def create
- @forward_auth_rule = ForwardAuthRule.new(forward_auth_rule_params)
- # Handle headers configuration
- @forward_auth_rule.headers_config = process_headers_config(params[:headers_config])
-
- if @forward_auth_rule.save
- # Handle group assignments
- if params[:forward_auth_rule][:group_ids].present?
- group_ids = params[:forward_auth_rule][:group_ids].reject(&:blank?)
- @forward_auth_rule.allowed_groups = Group.where(id: group_ids)
- end
-
- redirect_to admin_forward_auth_rule_path(@forward_auth_rule), notice: "Forward auth rule created successfully."
- else
- @available_groups = Group.order(:name)
- render :new, status: :unprocessable_entity
- end
- end
-
- def edit
- @available_groups = Group.order(:name)
- end
-
- def update
- if @forward_auth_rule.update(forward_auth_rule_params)
- # Handle headers configuration
- @forward_auth_rule.headers_config = process_headers_config(params[:headers_config])
- @forward_auth_rule.save!
-
- # Handle group assignments
- if params[:forward_auth_rule][:group_ids].present?
- group_ids = params[:forward_auth_rule][:group_ids].reject(&:blank?)
- @forward_auth_rule.allowed_groups = Group.where(id: group_ids)
- else
- @forward_auth_rule.allowed_groups = []
- end
-
- redirect_to admin_forward_auth_rule_path(@forward_auth_rule), notice: "Forward auth rule updated successfully."
- else
- @available_groups = Group.order(:name)
- render :edit, status: :unprocessable_entity
- end
- end
-
- def destroy
- @forward_auth_rule.destroy
- redirect_to admin_forward_auth_rules_path, notice: "Forward auth rule deleted successfully."
- end
-
- private
-
- def set_forward_auth_rule
- @forward_auth_rule = ForwardAuthRule.find(params[:id])
- end
-
- def forward_auth_rule_params
- params.require(:forward_auth_rule).permit(:domain_pattern, :active)
- end
-
- def process_headers_config(headers_params)
- return {} unless headers_params.is_a?(Hash)
-
- # Clean up headers config - remove empty values, keep only filled ones
- headers_params.select { |key, value| value.present? }.symbolize_keys
- end
- end
-end
\ No newline at end of file
diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb
index dc61a62..d27842d 100644
--- a/app/controllers/admin/groups_controller.rb
+++ b/app/controllers/admin/groups_controller.rb
@@ -67,7 +67,7 @@ module Admin
end
def group_params
- params.require(:group).permit(:name, :description)
+ params.require(:group).permit(:name, :description, custom_claims: {})
end
end
end
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index 3ca54c8..542dbae 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -76,7 +76,7 @@ module Admin
end
def user_params
- params.require(:user).permit(:email_address, :password, :admin, :status)
+ params.require(:user).permit(:email_address, :password, :admin, :status, custom_claims: {})
end
end
end
diff --git a/app/controllers/api/csp_controller.rb b/app/controllers/api/csp_controller.rb
new file mode 100644
index 0000000..6f257fa
--- /dev/null
+++ b/app/controllers/api/csp_controller.rb
@@ -0,0 +1,31 @@
+module Api
+ class CspController < ApplicationController
+ # CSP violation reports don't need authentication
+ skip_before_action :verify_authenticity_token
+ allow_unauthenticated_access
+
+ # POST /api/csp-violation-report
+ def violation_report
+ # Parse CSP violation report
+ report_data = JSON.parse(request.body.read)
+
+ # Log the violation for security monitoring
+ Rails.logger.warn "CSP Violation Report:"
+ Rails.logger.warn " Blocked URI: #{report_data.dig('csp-report', 'blocked-uri')}"
+ Rails.logger.warn " Document URI: #{report_data.dig('csp-report', 'document-uri')}"
+ Rails.logger.warn " Referrer: #{report_data.dig('csp-report', 'referrer')}"
+ Rails.logger.warn " Violated Directive: #{report_data.dig('csp-report', 'violated-directive')}"
+ Rails.logger.warn " Original Policy: #{report_data.dig('csp-report', 'original-policy')}"
+ Rails.logger.warn " User Agent: #{request.user_agent}"
+ Rails.logger.warn " IP Address: #{request.remote_ip}"
+
+ # In production, you might want to send this to a security monitoring service
+ # For now, we'll just log it and return a success response
+
+ head :no_content
+ rescue JSON::ParserError => e
+ Rails.logger.error "Invalid CSP violation report: #{e.message}"
+ head :bad_request
+ end
+ end
+end
\ No newline at end of file
diff --git a/app/controllers/api/forward_auth_controller.rb b/app/controllers/api/forward_auth_controller.rb
index 5a06ea2..bb29ecf 100644
--- a/app/controllers/api/forward_auth_controller.rb
+++ b/app/controllers/api/forward_auth_controller.rb
@@ -9,7 +9,7 @@ module Api
# This endpoint is called by reverse proxies (Traefik, Caddy, nginx)
# to verify if a user is authenticated and authorized to access a domain
def verify
- # Note: app_slug parameter is no longer used - we match domains directly with ForwardAuthRule
+ # Note: app_slug parameter is no longer used - we match domains directly with Application (forward_auth type)
# Check for one-time forward auth token first (to handle race condition)
session_id = check_forward_auth_token
@@ -44,37 +44,37 @@ module Api
return render_unauthorized("User account is not active")
end
- # Check for forward auth rule authorization
+ # Check for forward auth application authorization
# Get the forwarded host for domain matching
forwarded_host = request.headers["X-Forwarded-Host"] || request.headers["Host"]
if forwarded_host.present?
- # Load active rules with their associations for better performance
+ # Load active forward auth applications with their associations for better performance
# Preload groups to avoid N+1 queries in user_allowed? checks
- rules = ForwardAuthRule.includes(:allowed_groups).active
+ apps = Application.forward_auth.includes(:allowed_groups).active
- # Find matching forward auth rule for this domain
- rule = rules.find { |r| r.matches_domain?(forwarded_host) }
+ # Find matching forward auth application for this domain
+ app = apps.find { |a| a.matches_domain?(forwarded_host) }
- if rule
- # Check if user is allowed by this rule
- unless rule.user_allowed?(user)
- Rails.logger.info "ForwardAuth: User #{user.email_address} denied access to #{forwarded_host} by rule #{rule.domain_pattern}"
+ if app
+ # Check if user is allowed by this application
+ unless app.user_allowed?(user)
+ Rails.logger.info "ForwardAuth: User #{user.email_address} denied access to #{forwarded_host} by app #{app.domain_pattern}"
return render_forbidden("You do not have permission to access this domain")
end
- Rails.logger.info "ForwardAuth: User #{user.email_address} granted access to #{forwarded_host} by rule #{rule.domain_pattern} (policy: #{rule.policy_for_user(user)})"
+ Rails.logger.info "ForwardAuth: User #{user.email_address} granted access to #{forwarded_host} by app #{app.domain_pattern} (policy: #{app.policy_for_user(user)})"
else
- # No rule found - allow access with default headers (original behavior)
- Rails.logger.info "ForwardAuth: No rule found for domain: #{forwarded_host}, allowing with default headers"
+ # No application found - allow access with default headers (original behavior)
+ Rails.logger.info "ForwardAuth: No application found for domain: #{forwarded_host}, allowing with default headers"
end
else
Rails.logger.info "ForwardAuth: User #{user.email_address} authenticated (no domain specified)"
end
# User is authenticated and authorized
- # Return 200 with user information headers using rule-specific configuration
- headers = rule ? rule.headers_for_user(user) : ForwardAuthRule::DEFAULT_HEADERS.map { |key, header_name|
+ # Return 200 with user information headers using app-specific configuration
+ headers = app ? app.headers_for_user(user) : Application::DEFAULT_HEADERS.map { |key, header_name|
case key
when :user, :email, :name
[header_name, user.email_address]
@@ -127,7 +127,7 @@ module Api
end
def extract_app_from_headers
- # This method is deprecated since we now use ForwardAuthRule domain matching
+ # This method is deprecated since we now use Application (forward_auth type) domain matching
# Keeping it for backward compatibility but it's no longer used
nil
end
@@ -195,12 +195,12 @@ module Api
redirect_domain = uri.host.downcase
return nil unless redirect_domain.present?
- # Check against our ForwardAuthRules
- matching_rule = ForwardAuthRule.active.find do |rule|
- rule.matches_domain?(redirect_domain)
+ # Check against our ForwardAuth applications
+ matching_app = Application.forward_auth.active.find do |app|
+ app.matches_domain?(redirect_domain)
end
- matching_rule ? url : nil
+ matching_app ? url : nil
rescue URI::InvalidURIError
nil
@@ -210,8 +210,8 @@ module Api
def domain_has_forward_auth_rule?(domain)
return false if domain.blank?
- ForwardAuthRule.active.any? do |rule|
- rule.matches_domain?(domain.downcase)
+ Application.forward_auth.active.any? do |app|
+ app.matches_domain?(domain.downcase)
end
end
end
diff --git a/app/controllers/oidc_controller.rb b/app/controllers/oidc_controller.rb
index a8577f6..cd52552 100644
--- a/app/controllers/oidc_controller.rb
+++ b/app/controllers/oidc_controller.rb
@@ -302,6 +302,14 @@ class OidcController < ApplicationController
# Add admin claim if user is admin
claims[:admin] = true if user.admin?
+ # Merge custom claims from groups
+ user.groups.each do |group|
+ claims.merge!(group.parsed_custom_claims)
+ end
+
+ # Merge custom claims from user (overrides group claims)
+ claims.merge!(user.parsed_custom_claims)
+
render json: claims
end
diff --git a/app/models/application.rb b/app/models/application.rb
index 864bb56..159f291 100644
--- a/app/models/application.rb
+++ b/app/models/application.rb
@@ -1,53 +1,48 @@
class Application < ApplicationRecord
- has_secure_password :client_secret
+ has_secure_password :client_secret, validations: false
has_many :application_groups, dependent: :destroy
has_many :allowed_groups, through: :application_groups, source: :group
has_many :oidc_authorization_codes, dependent: :destroy
has_many :oidc_access_tokens, dependent: :destroy
has_many :oidc_user_consents, dependent: :destroy
- has_many :application_roles, dependent: :destroy
- has_many :user_role_assignments, through: :application_roles
validates :name, presence: true
validates :slug, presence: true, uniqueness: { case_sensitive: false },
format: { with: /\A[a-z0-9\-]+\z/, message: "only lowercase letters, numbers, and hyphens" }
validates :app_type, presence: true,
- inclusion: { in: %w[oidc saml] }
+ inclusion: { in: %w[oidc forward_auth] }
validates :client_id, uniqueness: { allow_nil: true }
- validates :role_mapping_mode, inclusion: { in: %w[disabled oidc_managed hybrid] }, allow_blank: true
+ validates :client_secret, presence: true, if: :oidc?
+ validates :domain_pattern, presence: true, uniqueness: { case_sensitive: false }, if: :forward_auth?
normalizes :slug, with: ->(slug) { slug.strip.downcase }
+ normalizes :domain_pattern, with: ->(pattern) { pattern&.strip&.downcase }
before_validation :generate_client_credentials, on: :create, if: :oidc?
+ # Default header configuration for ForwardAuth
+ DEFAULT_HEADERS = {
+ user: 'X-Remote-User',
+ email: 'X-Remote-Email',
+ name: 'X-Remote-Name',
+ groups: 'X-Remote-Groups',
+ admin: 'X-Remote-Admin'
+ }.freeze
+
# Scopes
scope :active, -> { where(active: true) }
scope :oidc, -> { where(app_type: "oidc") }
- scope :saml, -> { where(app_type: "saml") }
- scope :oidc_managed_roles, -> { where(role_mapping_mode: "oidc_managed") }
- scope :hybrid_roles, -> { where(role_mapping_mode: "hybrid") }
+ scope :forward_auth, -> { where(app_type: "forward_auth") }
+ scope :ordered, -> { order(domain_pattern: :asc) }
# Type checks
def oidc?
app_type == "oidc"
end
- def saml?
- app_type == "saml"
- end
-
- # Role mapping checks
- def role_mapping_enabled?
- role_mapping_mode.in?(['oidc_managed', 'hybrid'])
- end
-
- def oidc_managed_roles?
- role_mapping_mode == 'oidc_managed'
- end
-
- def hybrid_roles?
- role_mapping_mode == 'hybrid'
+ def forward_auth?
+ app_type == "forward_auth"
end
# Access control
@@ -77,49 +72,72 @@ class Application < ApplicationRecord
{}
end
- def parsed_managed_permissions
- return {} unless managed_permissions.present?
- managed_permissions.is_a?(Hash) ? managed_permissions : JSON.parse(managed_permissions)
+ # ForwardAuth helpers
+ def parsed_headers_config
+ return {} unless headers_config.present?
+ headers_config.is_a?(Hash) ? headers_config : JSON.parse(headers_config)
rescue JSON::ParserError
{}
end
- # Role management methods
- def user_roles(user)
- application_roles.joins(:user_role_assignments)
- .where(user_role_assignments: { user: user })
- .active
+ # Check if a domain matches this application's pattern (for ForwardAuth)
+ def matches_domain?(domain)
+ return false if domain.blank? || !forward_auth?
+
+ pattern = domain_pattern.gsub('.', '\.')
+ pattern = pattern.gsub('*', '[^.]*')
+
+ regex = Regexp.new("^#{pattern}$", Regexp::IGNORECASE)
+ regex.match?(domain.downcase)
end
- def user_has_role?(user, role_name)
- user_roles(user).exists?(name: role_name)
+ # Policy determination based on user status (for ForwardAuth)
+ def policy_for_user(user)
+ return 'deny' unless active?
+ return 'deny' unless user.active?
+
+ # If no groups specified, bypass authentication
+ return 'bypass' if allowed_groups.empty?
+
+ # If user is in allowed groups, determine auth level
+ if user_allowed?(user)
+ # Require 2FA if user has TOTP configured, otherwise one factor
+ user.totp_enabled? ? 'two_factor' : 'one_factor'
+ else
+ 'deny'
+ end
end
- def assign_role_to_user!(user, role_name, source: 'manual', metadata: {})
- role = application_roles.active.find_by!(name: role_name)
- role.assign_to_user!(user, source: source, metadata: metadata)
+ # Get effective header configuration (for ForwardAuth)
+ def effective_headers
+ DEFAULT_HEADERS.merge(parsed_headers_config.symbolize_keys)
end
- def remove_role_from_user!(user, role_name)
- role = application_roles.find_by!(name: role_name)
- role.remove_from_user!(user)
- end
+ # Generate headers for a specific user (for ForwardAuth)
+ def headers_for_user(user)
+ headers = {}
+ effective = effective_headers
- # Enhanced access control with roles
- def user_allowed_with_roles?(user)
- return user_allowed?(user) unless role_mapping_enabled?
+ # Only generate headers that are configured (not set to nil/false)
+ effective.each do |key, header_name|
+ next unless header_name.present? # Skip disabled headers
- # For OIDC managed roles, check if user has any roles assigned
- if oidc_managed_roles?
- return user_roles(user).exists?
+ case key
+ when :user, :email, :name
+ headers[header_name] = user.email_address
+ when :groups
+ headers[header_name] = user.groups.pluck(:name).join(",") if user.groups.any?
+ when :admin
+ headers[header_name] = user.admin? ? "true" : "false"
+ end
end
- # For hybrid mode, either group-based access or role-based access works
- if hybrid_roles?
- return user_allowed?(user) || user_roles(user).exists?
- end
+ headers
+ end
- user_allowed?(user)
+ # Check if all headers are disabled (for ForwardAuth)
+ def headers_disabled?
+ headers_config.present? && effective_headers.values.all?(&:blank?)
end
# Generate and return a new client secret
diff --git a/app/models/application_role.rb b/app/models/application_role.rb
deleted file mode 100644
index 91b5317..0000000
--- a/app/models/application_role.rb
+++ /dev/null
@@ -1,26 +0,0 @@
-class ApplicationRole < ApplicationRecord
- belongs_to :application
- has_many :user_role_assignments, dependent: :destroy
- has_many :users, through: :user_role_assignments
-
- validates :name, presence: true, uniqueness: { scope: :application_id }
- validates :display_name, presence: true
-
- scope :active, -> { where(active: true) }
-
- def user_has_role?(user)
- user_role_assignments.exists?(user: user)
- end
-
- def assign_to_user!(user, source: 'oidc', metadata: {})
- user_role_assignments.find_or_create_by!(user: user) do |assignment|
- assignment.source = source
- assignment.metadata = metadata
- end
- end
-
- def remove_from_user!(user)
- assignment = user_role_assignments.find_by(user: user)
- assignment&.destroy
- end
-end
\ No newline at end of file
diff --git a/app/models/forward_auth_rule.rb b/app/models/forward_auth_rule.rb
deleted file mode 100644
index 19f86c2..0000000
--- a/app/models/forward_auth_rule.rb
+++ /dev/null
@@ -1,94 +0,0 @@
-class ForwardAuthRule < ApplicationRecord
- has_many :forward_auth_rule_groups, dependent: :destroy
- has_many :allowed_groups, through: :forward_auth_rule_groups, source: :group
-
- validates :domain_pattern, presence: true, uniqueness: { case_sensitive: false }
- validates :active, inclusion: { in: [true, false] }
-
- normalizes :domain_pattern, with: ->(pattern) { pattern.strip.downcase }
-
- # Default header configuration
- DEFAULT_HEADERS = {
- user: 'X-Remote-User',
- email: 'X-Remote-Email',
- name: 'X-Remote-Name',
- groups: 'X-Remote-Groups',
- admin: 'X-Remote-Admin'
- }.freeze
-
- # Scopes
- scope :active, -> { where(active: true) }
- scope :ordered, -> { order(domain_pattern: :asc) }
-
- # Check if a domain matches this rule
- def matches_domain?(domain)
- return false if domain.blank?
-
- pattern = domain_pattern.gsub('.', '\.')
- pattern = pattern.gsub('*', '[^.]*')
-
- regex = Regexp.new("^#{pattern}$", Regexp::IGNORECASE)
- regex.match?(domain.downcase)
- end
-
- # Access control for forward auth
- def user_allowed?(user)
- return false unless active?
- return false unless user.active?
-
- # If no groups are specified, allow all active users (bypass)
- return true if allowed_groups.empty?
-
- # Otherwise, user must be in at least one of the allowed groups
- (user.groups & allowed_groups).any?
- end
-
- # Policy determination based on user status and rule configuration
- def policy_for_user(user)
- return 'deny' unless active?
- return 'deny' unless user.active?
-
- # If no groups specified, bypass authentication
- return 'bypass' if allowed_groups.empty?
-
- # If user is in allowed groups, determine auth level
- if user_allowed?(user)
- # Require 2FA if user has TOTP configured, otherwise one factor
- user.totp_enabled? ? 'two_factor' : 'one_factor'
- else
- 'deny'
- end
- end
-
- # Get effective header configuration (rule-specific + defaults)
- def effective_headers
- DEFAULT_HEADERS.merge((headers_config || {}).symbolize_keys)
- end
-
- # Generate headers for a specific user
- def headers_for_user(user)
- headers = {}
- effective = effective_headers
-
- # Only generate headers that are configured (not set to nil/false)
- effective.each do |key, header_name|
- next unless header_name.present? # Skip disabled headers
-
- case key
- when :user, :email, :name
- headers[header_name] = user.email_address
- when :groups
- headers[header_name] = user.groups.pluck(:name).join(",") if user.groups.any?
- when :admin
- headers[header_name] = user.admin? ? "true" : "false"
- end
- end
-
- headers
- end
-
- # Check if all headers are disabled
- def headers_disabled?
- headers_config.present? && effective_headers.values.all?(&:blank?)
- end
-end
diff --git a/app/models/forward_auth_rule_group.rb b/app/models/forward_auth_rule_group.rb
deleted file mode 100644
index 9973e76..0000000
--- a/app/models/forward_auth_rule_group.rb
+++ /dev/null
@@ -1,6 +0,0 @@
-class ForwardAuthRuleGroup < ApplicationRecord
- belongs_to :forward_auth_rule
- belongs_to :group
-
- validates :forward_auth_rule_id, uniqueness: { scope: :group_id }
-end
\ No newline at end of file
diff --git a/app/models/group.rb b/app/models/group.rb
index 54a9543..778b494 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -6,4 +6,9 @@ class Group < ApplicationRecord
validates :name, presence: true, uniqueness: { case_sensitive: false }
normalizes :name, with: ->(name) { name.strip.downcase }
+
+ # Parse custom_claims JSON field
+ def parsed_custom_claims
+ custom_claims || {}
+ end
end
diff --git a/app/models/user.rb b/app/models/user.rb
index e0b94a9..f20553f 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -3,8 +3,6 @@ class User < ApplicationRecord
has_many :sessions, dependent: :destroy
has_many :user_groups, dependent: :destroy
has_many :groups, through: :user_groups
- has_many :user_role_assignments, dependent: :destroy
- has_many :application_roles, through: :user_role_assignments
has_many :oidc_user_consents, dependent: :destroy
# Token generation for passwordless flows
@@ -97,6 +95,11 @@ class User < ApplicationRecord
oidc_user_consents.destroy_all
end
+ # Parse custom_claims JSON field
+ def parsed_custom_claims
+ custom_claims || {}
+ end
+
private
def generate_backup_codes
diff --git a/app/models/user_role_assignment.rb b/app/models/user_role_assignment.rb
deleted file mode 100644
index 84dba51..0000000
--- a/app/models/user_role_assignment.rb
+++ /dev/null
@@ -1,15 +0,0 @@
-class UserRoleAssignment < ApplicationRecord
- belongs_to :user
- belongs_to :application_role
-
- validates :user, uniqueness: { scope: :application_role }
- validates :source, inclusion: { in: %w[oidc manual group_sync] }
-
- scope :oidc_managed, -> { where(source: 'oidc') }
- scope :manually_assigned, -> { where(source: 'manual') }
- scope :group_synced, -> { where(source: 'group_sync') }
-
- def sync_from_oidc?
- source == 'oidc'
- end
-end
\ No newline at end of file
diff --git a/app/services/oidc_jwt_service.rb b/app/services/oidc_jwt_service.rb
index a17150a..00cee45 100644
--- a/app/services/oidc_jwt_service.rb
+++ b/app/services/oidc_jwt_service.rb
@@ -27,11 +27,14 @@ class OidcJwtService
# Add admin claim if user is admin
payload[:admin] = true if user.admin?
- # Add role-based claims if role mapping is enabled
- if application.role_mapping_enabled?
- add_role_claims!(payload, user, application)
+ # Merge custom claims from groups
+ user.groups.each do |group|
+ payload.merge!(group.parsed_custom_claims)
end
+ # Merge custom claims from user (overrides group claims)
+ payload.merge!(user.parsed_custom_claims)
+
JWT.encode(payload, private_key, "RS256", { kid: key_id, typ: "JWT" })
end
@@ -93,50 +96,5 @@ class OidcJwtService
def key_id
@key_id ||= Digest::SHA256.hexdigest(public_key.to_pem)[0..15]
end
-
- # Add role-based claims to the JWT payload
- def add_role_claims!(payload, user, application)
- user_roles = application.user_roles(user)
- return if user_roles.empty?
-
- role_names = user_roles.pluck(:name)
-
- # Filter roles by prefix if configured
- if application.role_prefix.present?
- role_names = role_names.select { |role| role.start_with?(application.role_prefix) }
- end
-
- return if role_names.empty?
-
- # Add roles using the configured claim name
- claim_name = application.role_claim_name.presence || 'roles'
- payload[claim_name] = role_names
-
- # Add role permissions if configured
- managed_permissions = application.parsed_managed_permissions
- if managed_permissions['include_permissions'] == true
- role_permissions = user_roles.map do |role|
- {
- name: role.name,
- display_name: role.display_name,
- permissions: role.permissions
- }
- end
- payload['role_permissions'] = role_permissions
- end
-
- # Add role metadata if configured
- if managed_permissions['include_metadata'] == true
- role_metadata = user_roles.map do |role|
- assignment = role.user_role_assignments.find_by(user: user)
- {
- name: role.name,
- source: assignment&.source,
- assigned_at: assignment&.created_at
- }
- end
- payload['role_metadata'] = role_metadata
- end
- end
end
end
diff --git a/app/services/role_mapping_engine.rb b/app/services/role_mapping_engine.rb
deleted file mode 100644
index f0ce8d9..0000000
--- a/app/services/role_mapping_engine.rb
+++ /dev/null
@@ -1,127 +0,0 @@
-class RoleMappingEngine
- class << self
- # Sync user roles from OIDC claims
- def sync_user_roles!(user, application, claims)
- return unless application.role_mapping_enabled?
-
- # Extract roles from claims
- external_roles = extract_roles_from_claims(application, claims)
-
- case application.role_mapping_mode
- when 'oidc_managed'
- sync_oidc_managed_roles!(user, application, external_roles)
- when 'hybrid'
- sync_hybrid_roles!(user, application, external_roles)
- end
- end
-
- # Check if user is allowed based on roles
- def user_allowed_with_roles?(user, application, claims = nil)
- return application.user_allowed_with_roles?(user) unless claims
-
- if application.oidc_managed_roles?
- external_roles = extract_roles_from_claims(application, claims)
- return false if external_roles.empty?
-
- # Check if any external role matches configured application roles
- application.application_roles.active.exists?(name: external_roles)
- elsif application.hybrid_roles?
- # Allow access if either group-based or role-based access works
- application.user_allowed?(user) ||
- (external_roles.present? &&
- application.application_roles.active.exists?(name: external_roles))
- else
- application.user_allowed?(user)
- end
- end
-
- # Get available roles for a user in an application
- def user_available_roles(user, application)
- return [] unless application.role_mapping_enabled?
-
- application.application_roles.active
- end
-
- # Map external roles to internal roles
- def map_external_to_internal_roles(application, external_roles)
- return [] if external_roles.empty?
-
- configured_roles = application.application_roles.active.pluck(:name)
-
- # Apply role prefix filtering
- if application.role_prefix.present?
- external_roles = external_roles.select { |role| role.start_with?(application.role_prefix) }
- end
-
- # Find matching internal roles
- external_roles & configured_roles
- end
-
- private
-
- # Extract roles from various claim sources
- def extract_roles_from_claims(application, claims)
- claim_name = application.role_claim_name.presence || 'roles'
-
- # Try the configured claim name first
- roles = claims[claim_name]
-
- # Fallback to common claim names if not found
- roles ||= claims['roles']
- roles ||= claims['groups']
- roles ||= claims['http://schemas.microsoft.com/ws/2008/06/identity/claims/role']
-
- # Ensure roles is an array
- case roles
- when String
- [roles]
- when Array
- roles
- else
- []
- end
- end
-
- # Sync roles for OIDC managed mode (replace existing roles)
- def sync_oidc_managed_roles!(user, application, external_roles)
- # Map external roles to internal roles
- internal_roles = map_external_to_internal_roles(application, external_roles)
-
- # Get current OIDC-managed roles
- current_assignments = user.user_role_assignments
- .joins(:application_role)
- .where(application_role: { application: application })
- .oidc_managed
- .includes(:application_role)
-
- current_role_names = current_assignments.map { |assignment| assignment.application_role.name }
-
- # Remove roles that are no longer in external roles
- roles_to_remove = current_role_names - internal_roles
- roles_to_remove.each do |role_name|
- application.remove_role_from_user!(user, role_name)
- end
-
- # Add new roles
- roles_to_add = internal_roles - current_role_names
- roles_to_add.each do |role_name|
- application.assign_role_to_user!(user, role_name, source: 'oidc',
- metadata: { synced_at: Time.current })
- end
- end
-
- # Sync roles for hybrid mode (merge with existing roles)
- def sync_hybrid_roles!(user, application, external_roles)
- # Map external roles to internal roles
- internal_roles = map_external_to_internal_roles(application, external_roles)
-
- # Only add new roles, don't remove manually assigned ones
- internal_roles.each do |role_name|
- next if application.user_has_role?(user, role_name)
-
- application.assign_role_to_user!(user, role_name, source: 'oidc',
- metadata: { synced_at: Time.current })
- end
- end
- end
-end
\ No newline at end of file
diff --git a/app/views/admin/applications/_form.html.erb b/app/views/admin/applications/_form.html.erb
index 97f0945..7387bdd 100644
--- a/app/views/admin/applications/_form.html.erb
+++ b/app/views/admin/applications/_form.html.erb
@@ -36,7 +36,7 @@
<%= form.label :app_type, "Application Type", class: "block text-sm font-medium text-gray-700" %>
- <%= form.select :app_type, [["OpenID Connect (OIDC)", "oidc"], ["SAML (Coming Soon)", "saml", { disabled: true }]], {}, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", disabled: application.persisted? %>
+ <%= form.select :app_type, [["OpenID Connect (OIDC)", "oidc"], ["Forward Auth (Reverse Proxy)", "forward_auth"]], {}, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", disabled: application.persisted? %>
<% if application.persisted? %>
Application type cannot be changed after creation.
<% end %>
@@ -51,51 +51,22 @@
<%= form.text_area :redirect_uris, rows: 4, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "https://example.com/callback\nhttps://app.example.com/auth/callback" %>
One URI per line. These are the allowed callback URLs for your application.
+
-
-
-
Role Mapping Configuration
+
+
+
Forward Auth Configuration
-
- <%= form.label :role_mapping_mode, "Role Mapping Mode", class: "block text-sm font-medium text-gray-700" %>
- <%= form.select :role_mapping_mode,
- options_for_select([
- ["Disabled", "disabled"],
- ["OIDC Managed", "oidc_managed"],
- ["Hybrid (Groups + Roles)", "hybrid"]
- ], application.role_mapping_mode || "disabled"),
- {},
- { class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" } %>
-
Controls how external roles are mapped and synchronized.
-
+
+ <%= form.label :domain_pattern, "Domain Pattern", class: "block text-sm font-medium text-gray-700" %>
+ <%= form.text_field :domain_pattern, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "*.example.com or app.example.com" %>
+
Domain pattern to match. Use * for wildcard subdomains (e.g., *.example.com matches app.example.com, api.example.com, etc.)
+
-
-
- <%= form.label :role_claim_name, "Role Claim Name", class: "block text-sm font-medium text-gray-700" %>
- <%= form.text_field :role_claim_name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "roles" %>
-
Name of the claim that contains role information (default: 'roles').
-
-
-
- <%= form.label :role_prefix, "Role Prefix (Optional)", class: "block text-sm font-medium text-gray-700" %>
- <%= form.text_field :role_prefix, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "app-" %>
-
Only roles starting with this prefix will be mapped. Useful for multi-tenant scenarios.
-
-
-
-
Managed Permissions
-
-
- <%= form.check_box :managed_permissions, { multiple: true, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" }, "include_permissions", "" %>
- <%= form.label :managed_permissions_include_permissions, "Include role permissions in tokens", class: "ml-2 block text-sm text-gray-900" %>
-
-
-
- <%= form.check_box :managed_permissions, { multiple: true, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" }, "include_metadata", "" %>
- <%= form.label :managed_permissions_include_metadata, "Include role metadata in tokens", class: "ml-2 block text-sm text-gray-900" %>
-
-
-
+
+ <%= form.label :headers_config, "Custom Headers Configuration (JSON)", class: "block text-sm font-medium text-gray-700" %>
+ <%= form.text_area :headers_config, rows: 8, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: '{"user": "X-Remote-User", "email": "X-Remote-Email"}' %>
+
Optional: Override default headers. Leave empty to use defaults: X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Groups, X-Remote-Admin
@@ -129,33 +100,29 @@
<% end %>
diff --git a/app/views/admin/applications/roles.html.erb b/app/views/admin/applications/roles.html.erb
deleted file mode 100644
index bcc53cc..0000000
--- a/app/views/admin/applications/roles.html.erb
+++ /dev/null
@@ -1,125 +0,0 @@
-<% content_for :title, "Role Management - #{@application.name}" %>
-
-
-
-
-
- Role Management for <%= @application.name %>
-
- <%= link_to "← Back to Application", admin_application_path(@application), class: "text-sm text-blue-600 hover:text-blue-500" %>
-
-
- <% if @application.role_mapping_enabled? %>
-
-
-
-
Role Mapping Configuration
-
-
Mode: <%= @application.role_mapping_mode.humanize %>
- <% if @application.role_claim_name.present? %>
-
Role Claim: <%= @application.role_claim_name %>
- <% end %>
- <% if @application.role_prefix.present? %>
-
Role Prefix: <%= @application.role_prefix %>
- <% end %>
-
-
-
-
- <% else %>
-
-
-
-
Role Mapping Disabled
-
-
Role mapping is currently disabled for this application. Enable it in the application settings to manage roles.
-
-
-
-
- <% end %>
-
-
-
-
Create New Role
- <%= form_with(model: [:admin, @application, ApplicationRole.new], url: create_role_admin_application_path(@application), local: true, class: "space-y-4") do |form| %>
-
-
- <%= form.label :name, "Role Name", class: "block text-sm font-medium text-gray-700" %>
- <%= form.text_field :name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "admin" %>
-
-
- <%= form.label :display_name, "Display Name", class: "block text-sm font-medium text-gray-700" %>
- <%= form.text_field :display_name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Administrator" %>
-
-
-
- <%= form.label :description, class: "block text-sm font-medium text-gray-700" %>
- <%= form.text_area :description, rows: 2, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Description of this role's permissions" %>
-
-
- <%= form.check_box :active, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
- <%= form.label :active, "Active", class: "ml-2 block text-sm text-gray-900" %>
-
-
- <%= form.submit "Create Role", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
-
- <% end %>
-
-
-
-
-
Existing Roles
-
- <% if @application_roles.any? %>
-
- <% @application_roles.each do |role| %>
-
-
-
-
-
<%= role.name %>
-
- <%= role.display_name %>
-
- <% unless role.active %>
-
- Inactive
-
- <% end %>
-
- <% if role.description.present? %>
-
<%= role.description %>
- <% end %>
-
-
-
-
Assigned Users:
-
- <% role.users.each do |user| %>
-
- <%= user.email_address %>
- (<%= role.user_role_assignments.find_by(user: user)&.source %>)
- <%= link_to "×", remove_role_admin_application_path(@application, user_id: user.id, role_id: role.id),
- method: :post,
- data: { confirm: "Remove role from #{user.email_address}?" },
- class: "ml-1 text-blue-600 hover:text-blue-800" %>
-
- <% end %>
-
-
-
-
-
- <% end %>
-
- <% else %>
-
-
- No roles configured yet. Create your first role above to get started with role-based access control.
-
-
- <% end %>
-
-
-
\ No newline at end of file
diff --git a/app/views/admin/applications/roles_backup.html.erb b/app/views/admin/applications/roles_backup.html.erb
deleted file mode 100644
index 594ccfa..0000000
--- a/app/views/admin/applications/roles_backup.html.erb
+++ /dev/null
@@ -1,173 +0,0 @@
-<% content_for :title, "Role Management - #{@application.name}" %>
-
-
-
-
-
- Role Management for <%= @application.name %>
-
- <%= link_to "← Back to Application", admin_application_path(@application), class: "text-sm text-blue-600 hover:text-blue-500" %>
-
-
- <% if @application.role_mapping_enabled? %>
-
-
-
-
Role Mapping Configuration
-
-
Mode: <%= @application.role_mapping_mode.humanize %>
- <% if @application.role_claim_name.present? %>
-
Role Claim: <%= @application.role_claim_name %>
- <% end %>
- <% if @application.role_prefix.present? %>
-
Role Prefix: <%= @application.role_prefix %>
- <% end %>
-
-
-
-
- <% else %>
-
-
-
-
Role Mapping Disabled
-
-
Role mapping is currently disabled for this application. Enable it in the application settings to manage roles.
-
-
-
-
- <% end %>
-
-
-
-
Create New Role
- <%= form_with(model: [:admin, @application, ApplicationRole.new], url: create_role_admin_application_path(@application), local: true, class: "space-y-4") do |form| %>
-
-
- <%= form.label :name, "Role Name", class: "block text-sm font-medium text-gray-700" %>
- <%= form.text_field :name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "admin" %>
-
-
- <%= form.label :display_name, "Display Name", class: "block text-sm font-medium text-gray-700" %>
- <%= form.text_field :display_name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Administrator" %>
-
-
-
- <%= form.label :description, class: "block text-sm font-medium text-gray-700" %>
- <%= form.text_area :description, rows: 2, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Description of this role's permissions" %>
-
-
- <%= form.check_box :active, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
- <%= form.label :active, "Active", class: "ml-2 block text-sm text-gray-900" %>
-
-
- <%= form.submit "Create Role", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
-
- <% end %>
-
-
-
-
-
Existing Roles
-
- <% if @application_roles.any? %>
-
- <% @application_roles.each do |role| %>
-
-
-
-
-
<%= role.name %>
-
- <%= role.display_name %>
-
- <% unless role.active %>
-
- Inactive
-
- <% end %>
-
- <% if role.description.present? %>
-
<%= role.description %>
- <% end %>
-
-
-
-
Assigned Users:
-
- <% role.users.each do |user| %>
-
- <%= user.email_address %>
- (<%= role.user_role_assignments.find_by(user: user)&.source %>)
- <%= link_to "×", remove_role_admin_application_path(@application, user_id: user.id, role_id: role.id),
- method: :post,
- data: { confirm: "Remove role from #{user.email_address}?" },
- class: "ml-1 text-blue-600 hover:text-blue-800" %>
-
- <% end %>
-
-
-
-
-
-
-
-
-
-
- Assign to user...
- <% @available_users.each do |user| %>
- <% unless role.user_has_role?(user) %>
- <%= user.email_address %>
- <% end %>
- <% end %>
-
- <%= link_to "Assign", assign_role_admin_application_path(@application, role_id: role.id, user_id: "REPLACE_USER_ID"),
- method: :post,
- class: "text-xs bg-blue-600 px-2 py-1 rounded text-white hover:bg-blue-500",
- onclick: "this.href = this.href.replace('REPLACE_USER_ID', document.getElementById('assign-user-<%= role.id %>').value); if (this.href.includes('undefined')) { alert('Please select a user'); return false; }" %>
-
-
-
- <%= link_to "Edit", "#", class: "text-xs text-gray-600 hover:text-gray-800", onclick: "document.getElementById('edit-role-<%= role.id %>').classList.toggle('hidden'); return false;" %>
-
-
-
-
-
-
- <%= form_with(model: [:admin, @application, role], url: update_role_admin_application_path(@application, role_id: role.id), local: true, method: :patch, class: "space-y-3") do |form| %>
-
-
- <%= form.label :display_name, "Display Name", class: "block text-sm font-medium text-gray-700" %>
- <%= form.text_field :display_name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
-
-
- <%= form.check_box :active, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
- <%= form.label :active, "Active", class: "ml-2 block text-sm text-gray-900" %>
-
-
-
- <%= form.label :description, class: "block text-sm font-medium text-gray-700" %>
- <%= form.text_area :description, rows: 2, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
-
-
- <%= form.submit "Update Role", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500" %>
- <%= link_to "Cancel", "#", class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50", onclick: "document.getElementById('edit-role-<%= role.id %>').classList.add('hidden'); return false;" %>
-
- <% end %>
-
-
- <% end %>
-
- <% else %>
-
-
- No roles configured yet. Create your first role above to get started with role-based access control.
-
-
- <% end %>
-
-
-
\ No newline at end of file
diff --git a/app/views/admin/applications/roles_broken.html.erb b/app/views/admin/applications/roles_broken.html.erb
deleted file mode 100644
index 77de5ed..0000000
--- a/app/views/admin/applications/roles_broken.html.erb
+++ /dev/null
@@ -1,179 +0,0 @@
-<% content_for :title, "Role Management - #{@application.name}" %>
-
-
-
-
-
- Role Management for <%= @application.name %>
-
- <%= link_to "← Back to Application", admin_application_path(@application), class: "text-sm text-blue-600 hover:text-blue-500" %>
-
-
- <% if @application.role_mapping_enabled? %>
-
-
-
-
Role Mapping Configuration
-
-
Mode: <%= @application.role_mapping_mode.humanize %>
- <% if @application.role_claim_name.present? %>
-
Role Claim: <%= @application.role_claim_name %>
- <% end %>
- <% if @application.role_prefix.present? %>
-
Role Prefix: <%= @application.role_prefix %>
- <% end %>
-
-
-
-
- <% else %>
-
-
-
-
Role Mapping Disabled
-
-
Role mapping is currently disabled for this application. Enable it in the application settings to manage roles.
-
-
-
-
- <% end %>
-
-
-
-
Create New Role
- <%= form_with(model: [:admin, @application, ApplicationRole.new], url: create_role_admin_application_path(@application), local: true, class: "space-y-4") do |form| %>
-
-
- <%= form.label :name, "Role Name", class: "block text-sm font-medium text-gray-700" %>
- <%= form.text_field :name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "admin" %>
-
-
- <%= form.label :display_name, "Display Name", class: "block text-sm font-medium text-gray-700" %>
- <%= form.text_field :display_name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Administrator" %>
-
-
-
- <%= form.label :description, class: "block text-sm font-medium text-gray-700" %>
- <%= form.text_area :description, rows: 2, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Description of this role's permissions" %>
-
-
- <%= form.check_box :active, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
- <%= form.label :active, "Active", class: "ml-2 block text-sm text-gray-900" %>
-
-
- <%= form.submit "Create Role", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
-
- <% end %>
-
-
-
-
-
Existing Roles
-
- <% if @application_roles.any? %>
-
- <% @application_roles.each do |role| %>
-
-
-
-
-
<%= role.name %>
-
- <%= role.display_name %>
-
- <% unless role.active %>
-
- Inactive
-
- <% end %>
-
- <% if role.description.present? %>
-
<%= role.description %>
- <% end %>
-
-
-
-
Assigned Users:
-
- <% role.users.each do |user| %>
-
- <%= user.email_address %>
- (<%= role.user_role_assignments.find_by(user: user)&.source %>)
- <%= link_to "×", remove_role_admin_application_path(@application, user_id: user.id, role_id: role.id),
- method: :post,
- data: { confirm: "Remove role from #{user.email_address}?" },
- class: "ml-1 text-blue-600 hover:text-blue-800" %>
-
- <% end %>
-
-
-
-
-
-
-
-
-
-
- Assign to user...
- <% @available_users.each do |user| %>
- <% unless role.user_has_role?(user) %>
- <%= user.email_address %>
- <% end %>
- <% end %>
-
- <%= link_to "Assign", assign_role_admin_application_path(@application, role_id: role.id, user_id: "PLACEHOLDER"),
- method: :post,
- class: "text-xs bg-blue-600 px-2 py-1 rounded text-white hover:bg-blue-500",
- data: { role_target: "assignLink", action: "click->role-management#assignRole" } %>
-
-
-
- <%= link_to "Edit", "#",
- class: "text-xs text-gray-600 hover:text-gray-800",
- data: { action: "click->role-management#toggleEdit" },
- data: { role_id: role.id } %>
-
-
-
-
-
-
- <%= form_with(model: [:admin, @application, role], url: update_role_admin_application_path(@application, role_id: role.id), local: true, method: :patch, class: "space-y-3") do |form| %>
-
-
- <%= form.label :display_name, "Display Name", class: "block text-sm font-medium text-gray-700" %>
- <%= form.text_field :display_name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
-
-
- <%= form.check_box :active, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
- <%= form.label :active, "Active", class: "ml-2 block text-sm text-gray-900" %>
-
-
-
- <%= form.label :description, class: "block text-sm font-medium text-gray-700" %>
- <%= form.text_area :description, rows: 2, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
-
-
- <%= form.submit "Update Role", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500" %>
- <%= link_to "Cancel", "#",
- class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50",
- data: { action: "click->role-management#hideEdit" },
- data: { role_id: role.id } %>
-
- <% end %>
-
-
- <% end %>
-
- <% else %>
-
-
- No roles configured yet. Create your first role above to get started with role-based access control.
-
-
- <% end %>
-
-
-
\ No newline at end of file
diff --git a/app/views/admin/applications/roles_complex.html.erb b/app/views/admin/applications/roles_complex.html.erb
deleted file mode 100644
index b08e81a..0000000
--- a/app/views/admin/applications/roles_complex.html.erb
+++ /dev/null
@@ -1,173 +0,0 @@
-<% content_for :title, "Role Management - #{@application.name}" %>
-
-
-
-
-
- Role Management for <%= @application.name %>
-
- <%= link_to "← Back to Application", admin_application_path(@application), class: "text-sm text-blue-600 hover:text-blue-500" %>
-
-
- <% if @application.role_mapping_enabled? %>
-
-
-
-
Role Mapping Configuration
-
-
Mode: <%= @application.role_mapping_mode.humanize %>
- <% if @application.role_claim_name.present? %>
-
Role Claim: <%= @application.role_claim_name %>
- <% end %>
- <% if @application.role_prefix.present? %>
-
Role Prefix: <%= @application.role_prefix %>
- <% end %>
-
-
-
-
- <% else %>
-
-
-
-
Role Mapping Disabled
-
-
Role mapping is currently disabled for this application. Enable it in the application settings to manage roles.
-
-
-
-
- <% end %>
-
-
-
-
Create New Role
- <%= form_with(model: [:admin, @application, ApplicationRole.new], url: create_role_admin_application_path(@application), local: true, class: "space-y-4") do |form| %>
-
-
- <%= form.label :name, "Role Name", class: "block text-sm font-medium text-gray-700" %>
- <%= form.text_field :name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "admin" %>
-
-
- <%= form.label :display_name, "Display Name", class: "block text-sm font-medium text-gray-700" %>
- <%= form.text_field :display_name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Administrator" %>
-
-
-
- <%= form.label :description, class: "block text-sm font-medium text-gray-700" %>
- <%= form.text_area :description, rows: 2, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Description of this role's permissions" %>
-
-
- <%= form.check_box :active, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
- <%= form.label :active, "Active", class: "ml-2 block text-sm text-gray-900" %>
-
-
- <%= form.submit "Create Role", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
-
- <% end %>
-
-
-
-
-
Existing Roles
-
- <% if @application_roles.any? %>
-
- <% @application_roles.each do |role| %>
-
-
-
-
-
<%= role.name %>
-
- <%= role.display_name %>
-
- <% unless role.active %>
-
- Inactive
-
- <% end %>
-
- <% if role.description.present? %>
-
<%= role.description %>
- <% end %>
-
-
-
-
Assigned Users:
-
- <% role.users.each do |user| %>
-
- <%= user.email_address %>
- (<%= role.user_role_assignments.find_by(user: user)&.source %>)
- <%= link_to "×", remove_role_admin_application_path(@application, user_id: user.id, role_id: role.id),
- method: :post,
- data: { confirm: "Remove role from #{user.email_address}?" },
- class: "ml-1 text-blue-600 hover:text-blue-800" %>
-
- <% end %>
-
-
-
-
-
-
-
-
-
-
- Assign to user...
- <% @available_users.each do |user| %>
- <% unless role.user_has_role?(user) %>
- <%= user.email_address %>
- <% end %>
- <% end %>
-
- <%= link_to "Assign", assign_role_admin_application_path(@application, role_id: role.id, user_id: "PLACEHOLDER"),
- method: :post,
- class: "text-xs bg-blue-600 px-2 py-1 rounded text-white hover:bg-blue-500",
- onclick: "var select = document.getElementById('assign-user-<%= role.id %>'); var userId = select.value; if (!userId) { alert('Please select a user'); return false; } this.href = this.href.replace('PLACEHOLDER', userId);" %>
-
-
-
- <%= link_to "Edit", "#", class: "text-xs text-gray-600 hover:text-gray-800", onclick: "document.getElementById('edit-role-<%= role.id %>').classList.toggle('hidden'); return false;" %>
-
-
-
-
-
-
- <%= form_with(model: [:admin, @application, role], url: update_role_admin_application_path(@application, role_id: role.id), local: true, method: :patch, class: "space-y-3") do |form| %>
-
-
- <%= form.label :display_name, "Display Name", class: "block text-sm font-medium text-gray-700" %>
- <%= form.text_field :display_name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
-
-
- <%= form.check_box :active, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
- <%= form.label :active, "Active", class: "ml-2 block text-sm text-gray-900" %>
-
-
-
- <%= form.label :description, class: "block text-sm font-medium text-gray-700" %>
- <%= form.text_area :description, rows: 2, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
-
-
- <%= form.submit "Update Role", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500" %>
- <%= link_to "Cancel", "#", class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50", onclick: "document.getElementById('edit-role-<%= role.id %>').classList.add('hidden'); return false;" %>
-
- <% end %>
-
-
- <% end %>
-
- <% else %>
-
-
- No roles configured yet. Create your first role above to get started with role-based access control.
-
-
- <% end %>
-
-
-
\ No newline at end of file
diff --git a/app/views/admin/applications/show.html.erb b/app/views/admin/applications/show.html.erb
index 37ffb9a..5c69212 100644
--- a/app/views/admin/applications/show.html.erb
+++ b/app/views/admin/applications/show.html.erb
@@ -23,9 +23,6 @@
<%= link_to "Edit", edit_admin_application_path(@application), class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
- <% if @application.oidc? %>
- <%= link_to "Manage Roles", roles_admin_application_path(@application), class: "rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500" %>
- <% end %>
<%= button_to "Delete", admin_application_path(@application), method: :delete, data: { turbo_confirm: "Are you sure?" }, class: "rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500" %>
@@ -47,8 +44,8 @@
<% case @application.app_type %>
<% when "oidc" %>
OIDC
- <% when "saml" %>
- SAML
+ <% when "forward_auth" %>
+ Forward Auth
<% end %>
@@ -109,6 +106,35 @@
<% end %>
+
+ <% if @application.forward_auth? %>
+
+
+
Forward Auth Configuration
+
+
+
Domain Pattern
+
+ <%= @application.domain_pattern %>
+
+
+
+
Headers Configuration
+
+ <% if @application.headers_config.present? && @application.headers_config.any? %>
+ <%= JSON.pretty_generate(@application.headers_config) %>
+ <% else %>
+
+ Using default headers: X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Groups, X-Remote-Admin
+
+ <% end %>
+
+
+
+
+
+ <% end %>
+
diff --git a/app/views/admin/forward_auth_rules/edit.html.erb b/app/views/admin/forward_auth_rules/edit.html.erb
deleted file mode 100644
index 6c3d302..0000000
--- a/app/views/admin/forward_auth_rules/edit.html.erb
+++ /dev/null
@@ -1,126 +0,0 @@
-<% content_for :title, "Edit Forward Auth Rule" %>
-
-
-
-
- Edit Forward Auth Rule
-
-
-
-
-
- <%= form_with(model: [:admin, @forward_auth_rule], local: true, class: "space-y-6") do |form| %>
- <%= render "shared/form_errors", form: form %>
-
-
-
-
-
- <%= form.label :domain_pattern, class: "block text-sm font-medium leading-6 text-gray-900" %>
-
- <%= form.text_field :domain_pattern, class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6", placeholder: "*.example.com" %>
-
-
- Use patterns like "*.example.com" or "api.example.com". Wildcards (*) are supported.
-
-
-
-
- <%= form.label :active, class: "block text-sm font-medium leading-6 text-gray-900" %>
-
- <%= form.select :active, options_for_select([["Active", true], ["Inactive", false]], @forward_auth_rule.active), { prompt: "Select status" }, { class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:max-w-xs sm:text-sm sm:leading-6" } %>
-
-
-
-
-
- Groups
-
-
- <%= form.collection_select :group_ids, @available_groups, :id, :name,
- { selected: @forward_auth_rule.allowed_groups.map(&:id), prompt: "Select groups (leave empty for bypass)" },
- { multiple: true, class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6" } %>
-
-
- Select groups that are allowed to access this domain. If no groups are selected, all authenticated users will be allowed access (bypass).
-
-
-
-
-
- HTTP Headers Configuration
-
-
-
-
- <%= label_tag "headers_config[user]", "User Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
-
- <%= text_field_tag "headers_config[user]", @forward_auth_rule.headers_config&.dig(:user) || ForwardAuthRule::DEFAULT_HEADERS[:user],
- class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
- placeholder: "Remote-User" %>
-
-
Header name for user identity
-
-
-
- <%= label_tag "headers_config[email]", "Email Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
-
- <%= text_field_tag "headers_config[email]", @forward_auth_rule.headers_config&.dig(:email) || ForwardAuthRule::DEFAULT_HEADERS[:email],
- class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
- placeholder: "Remote-Email" %>
-
-
Header name for user email
-
-
-
- <%= label_tag "headers_config[name]", "Name Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
-
- <%= text_field_tag "headers_config[name]", @forward_auth_rule.headers_config&.dig(:name) || ForwardAuthRule::DEFAULT_HEADERS[:name],
- class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
- placeholder: "Remote-Name" %>
-
-
Header name for user display name
-
-
-
- <%= label_tag "headers_config[groups]", "Groups Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
-
- <%= text_field_tag "headers_config[groups]", @forward_auth_rule.headers_config&.dig(:groups) || ForwardAuthRule::DEFAULT_HEADERS[:groups],
- class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
- placeholder: "Remote-Groups" %>
-
-
Header name for user groups (comma-separated)
-
-
-
- <%= label_tag "headers_config[admin]", "Admin Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
-
- <%= text_field_tag "headers_config[admin]", @forward_auth_rule.headers_config&.dig(:admin) || ForwardAuthRule::DEFAULT_HEADERS[:admin],
- class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
- placeholder: "Remote-Admin" %>
-
-
Header name for admin status (true/false)
-
-
-
-
-
Header Configuration Options:
-
- • Default headers: Use standard headers like Remote-User, Remote-Email
- • X- prefixed: Use X-Remote-User, X-Remote-Email, etc.
- • Custom: Use application-specific headers
- • No headers: Leave fields empty for access-only (like Metube)
-
-
-
-
-
-
-
-
-
- <%= link_to "Cancel", admin_forward_auth_rule_path(@forward_auth_rule), class: "text-sm font-semibold leading-6 text-gray-900 hover:text-gray-700" %>
- <%= form.submit "Update Rule", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
-
- <% end %>
-
\ No newline at end of file
diff --git a/app/views/admin/forward_auth_rules/index.html.erb b/app/views/admin/forward_auth_rules/index.html.erb
deleted file mode 100644
index 1fdc91d..0000000
--- a/app/views/admin/forward_auth_rules/index.html.erb
+++ /dev/null
@@ -1,68 +0,0 @@
-
-
-
Forward Auth Rules
-
Manage forward authentication rules for domain-based access control.
-
-
- <%= link_to "New Rule", new_admin_forward_auth_rule_path, class: "block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
-
-
-
-
-
-
-
-
-
- Domain Pattern
- Headers
- Groups
- Status
-
- Actions
-
-
-
-
- <% @forward_auth_rules.each do |rule| %>
-
-
- <%= link_to rule.domain_pattern, admin_forward_auth_rule_path(rule), class: "text-blue-600 hover:text-blue-900" %>
-
-
- <% if rule.headers_config.blank? %>
- Default
- <% elsif rule.headers_config.values.all?(&:blank?) %>
- None
- <% else %>
- Custom
- <% end %>
-
-
- <% if rule.allowed_groups.empty? %>
- All users
- <% else %>
- <%= rule.allowed_groups.count %> groups
- <% end %>
-
-
- <% if rule.active? %>
- Active
- <% else %>
- Inactive
- <% end %>
-
-
-
- <%= link_to "View", admin_forward_auth_rule_path(rule), class: "text-blue-600 hover:text-blue-900 whitespace-nowrap" %>
- <%= link_to "Edit", edit_admin_forward_auth_rule_path(rule), class: "text-blue-600 hover:text-blue-900 whitespace-nowrap" %>
- <%= button_to "Delete", admin_forward_auth_rule_path(rule), method: :delete, data: { turbo_confirm: "Are you sure you want to delete this forward auth rule?" }, class: "text-red-600 hover:text-red-900 whitespace-nowrap" %>
-
-
-
- <% end %>
-
-
-
-
-
\ No newline at end of file
diff --git a/app/views/admin/forward_auth_rules/new.html.erb b/app/views/admin/forward_auth_rules/new.html.erb
deleted file mode 100644
index cd3f16d..0000000
--- a/app/views/admin/forward_auth_rules/new.html.erb
+++ /dev/null
@@ -1,126 +0,0 @@
-<% content_for :title, "New Forward Auth Rule" %>
-
-
-
-
- New Forward Auth Rule
-
-
-
-
-
- <%= form_with(model: [:admin, @forward_auth_rule], local: true, class: "space-y-6") do |form| %>
- <%= render "shared/form_errors", form: form %>
-
-
-
-
-
- <%= form.label :domain_pattern, class: "block text-sm font-medium leading-6 text-gray-900" %>
-
- <%= form.text_field :domain_pattern, class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6", placeholder: "*.example.com" %>
-
-
- Use patterns like "*.example.com" or "api.example.com". Wildcards (*) are supported.
-
-
-
-
- <%= form.label :active, class: "block text-sm font-medium leading-6 text-gray-900" %>
-
- <%= form.select :active, options_for_select([["Active", true], ["Inactive", false]], @forward_auth_rule.active), { prompt: "Select status" }, { class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:max-w-xs sm:text-sm sm:leading-6" } %>
-
-
-
-
-
- Groups
-
-
- <%= form.collection_select :group_ids, @available_groups, :id, :name,
- { prompt: "Select groups (leave empty for bypass)" },
- { multiple: true, class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6" } %>
-
-
- Select groups that are allowed to access this domain. If no groups are selected, all authenticated users will be allowed access (bypass).
-
-
-
-
-
- HTTP Headers Configuration
-
-
-
-
- <%= label_tag "headers_config[user]", "User Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
-
- <%= text_field_tag "headers_config[user]", @forward_auth_rule.headers_config&.dig(:user) || ForwardAuthRule::DEFAULT_HEADERS[:user],
- class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
- placeholder: "Remote-User" %>
-
-
Header name for user identity
-
-
-
- <%= label_tag "headers_config[email]", "Email Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
-
- <%= text_field_tag "headers_config[email]", @forward_auth_rule.headers_config&.dig(:email) || ForwardAuthRule::DEFAULT_HEADERS[:email],
- class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
- placeholder: "Remote-Email" %>
-
-
Header name for user email
-
-
-
- <%= label_tag "headers_config[name]", "Name Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
-
- <%= text_field_tag "headers_config[name]", @forward_auth_rule.headers_config&.dig(:name) || ForwardAuthRule::DEFAULT_HEADERS[:name],
- class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
- placeholder: "Remote-Name" %>
-
-
Header name for user display name
-
-
-
- <%= label_tag "headers_config[groups]", "Groups Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
-
- <%= text_field_tag "headers_config[groups]", @forward_auth_rule.headers_config&.dig(:groups) || ForwardAuthRule::DEFAULT_HEADERS[:groups],
- class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
- placeholder: "Remote-Groups" %>
-
-
Header name for user groups (comma-separated)
-
-
-
- <%= label_tag "headers_config[admin]", "Admin Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
-
- <%= text_field_tag "headers_config[admin]", @forward_auth_rule.headers_config&.dig(:admin) || ForwardAuthRule::DEFAULT_HEADERS[:admin],
- class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
- placeholder: "Remote-Admin" %>
-
-
Header name for admin status (true/false)
-
-
-
-
-
Header Configuration Options:
-
- • Default headers: Use standard headers like Remote-User, Remote-Email
- • X- prefixed: Use X-Remote-User, X-Remote-Email, etc.
- • Custom: Use application-specific headers
- • No headers: Leave fields empty for access-only (like Metube)
-
-
-
-
-
-
-
-
-
- <%= link_to "Cancel", admin_forward_auth_rules_path, class: "text-sm font-semibold leading-6 text-gray-900 hover:text-gray-700" %>
- <%= form.submit "Create Rule", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
-
- <% end %>
-
\ No newline at end of file
diff --git a/app/views/admin/forward_auth_rules/show.html.erb b/app/views/admin/forward_auth_rules/show.html.erb
deleted file mode 100644
index 6e1953e..0000000
--- a/app/views/admin/forward_auth_rules/show.html.erb
+++ /dev/null
@@ -1,116 +0,0 @@
-
-
-
-
<%= @forward_auth_rule.domain_pattern %>
-
Forward authentication rule for domain-based access control
-
-
- <%= link_to "Edit", edit_admin_forward_auth_rule_path(@forward_auth_rule), class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
- <%= button_to "Delete", admin_forward_auth_rule_path(@forward_auth_rule), method: :delete, data: { turbo_confirm: "Are you sure?" }, class: "rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500" %>
-
-
-
-
-
-
-
-
-
Basic Information
-
-
-
Domain Pattern
- <%= @forward_auth_rule.domain_pattern %>
-
-
-
Status
-
- <% if @forward_auth_rule.active? %>
- Active
- <% else %>
- Inactive
- <% end %>
-
-
-
-
Headers Configuration
-
- <% if @forward_auth_rule.headers_config.blank? %>
- Default
- <% elsif @forward_auth_rule.headers_config.values.all?(&:blank?) %>
- None
- <% else %>
- Custom
- <% end %>
-
-
-
-
-
-
-
-
-
-
Header Configuration
-
- <% effective_headers = @forward_auth_rule.effective_headers %>
-
- <% if effective_headers.empty? %>
-
-
-
-
- No headers configured - access control only.
-
-
-
-
- <% else %>
-
- <% effective_headers.each do |key, header_name| %>
-
-
<%= key.to_s.capitalize %>
-
- <%= header_name %>
-
-
- <% end %>
-
- <% end %>
-
-
-
-
-
-
-
-
Access Control
-
-
Allowed Groups
-
- <% if @allowed_groups.empty? %>
-
-
-
-
- No groups assigned - all active users can access this domain.
-
-
-
-
- <% else %>
-
- <% @allowed_groups.each do |group| %>
-
-
-
<%= group.name %>
-
<%= pluralize(group.users.count, "member") %>
-
-
- <% end %>
-
- <% end %>
-
-
-
-
-
\ No newline at end of file
diff --git a/app/views/admin/groups/_form.html.erb b/app/views/admin/groups/_form.html.erb
index fff7fc0..c7565ae 100644
--- a/app/views/admin/groups/_form.html.erb
+++ b/app/views/admin/groups/_form.html.erb
@@ -49,6 +49,12 @@
Select which users should be members of this group.
+
+ <%= form.label :custom_claims, "Custom Claims (JSON)", class: "block text-sm font-medium text-gray-700" %>
+ <%= form.text_area :custom_claims, value: (group.custom_claims.present? ? JSON.pretty_generate(group.custom_claims) : ""), rows: 8, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: '{"roles": ["admin", "editor"]}' %>
+
Optional: Custom claims to add to OIDC tokens for all members. These will be merged with user-level claims.
+
+
<%= form.submit group.persisted? ? "Update Group" : "Create Group", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
<%= link_to "Cancel", admin_groups_path, class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
diff --git a/app/views/admin/users/_form.html.erb b/app/views/admin/users/_form.html.erb
index 646f9b7..6f46488 100644
--- a/app/views/admin/users/_form.html.erb
+++ b/app/views/admin/users/_form.html.erb
@@ -46,6 +46,12 @@
<% end %>
+
+ <%= form.label :custom_claims, "Custom Claims (JSON)", class: "block text-sm font-medium text-gray-700" %>
+ <%= form.text_area :custom_claims, value: (user.custom_claims.present? ? JSON.pretty_generate(user.custom_claims) : ""), rows: 8, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: '{"department": "engineering", "level": "senior"}' %>
+
Optional: User-specific custom claims to add to OIDC tokens. These override group-level claims.
+
+
<%= form.submit user.persisted? ? "Update User" : "Create User", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
<%= link_to "Cancel", admin_users_path, class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
diff --git a/app/views/shared/_sidebar.html.erb b/app/views/shared/_sidebar.html.erb
index 7facb76..3336732 100644
--- a/app/views/shared/_sidebar.html.erb
+++ b/app/views/shared/_sidebar.html.erb
@@ -57,16 +57,6 @@
<% end %>
-
-
- <%= link_to admin_forward_auth_rules_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/forward_auth_rules') ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }" do %>
-
-
-
- Forward Auth Rules
- <% end %>
-
-
<%= link_to admin_groups_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/groups') ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }" do %>
@@ -170,14 +160,6 @@
Groups
<% end %>
-
- <%= link_to admin_forward_auth_rules_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %>
-
-
-
- Forward Auth Rules
- <% end %>
-
<% end %>
<%= link_to profile_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %>
diff --git a/config/routes.rb b/config/routes.rb
index 71d05d1..f6266b4 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -66,15 +66,9 @@ Rails.application.routes.draw do
resources :applications do
member do
post :regenerate_credentials
- get :roles
- post :create_role
- patch :update_role
- post :assign_role
- post :remove_role
end
end
resources :groups
- resources :forward_auth_rules
end
# Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb)
diff --git a/db/migrate/20251104014854_add_custom_claims_to_groups_and_users.rb b/db/migrate/20251104014854_add_custom_claims_to_groups_and_users.rb
new file mode 100644
index 0000000..5d88080
--- /dev/null
+++ b/db/migrate/20251104014854_add_custom_claims_to_groups_and_users.rb
@@ -0,0 +1,6 @@
+class AddCustomClaimsToGroupsAndUsers < ActiveRecord::Migration[8.1]
+ def change
+ add_column :groups, :custom_claims, :json, default: {}, null: false
+ add_column :users, :custom_claims, :json, default: {}, null: false
+ end
+end
diff --git a/db/migrate/20251104014928_add_forward_auth_fields_to_applications.rb b/db/migrate/20251104014928_add_forward_auth_fields_to_applications.rb
new file mode 100644
index 0000000..6274a57
--- /dev/null
+++ b/db/migrate/20251104014928_add_forward_auth_fields_to_applications.rb
@@ -0,0 +1,10 @@
+class AddForwardAuthFieldsToApplications < ActiveRecord::Migration[8.1]
+ def change
+ # Add ForwardAuth-specific fields
+ add_column :applications, :domain_pattern, :string
+ add_column :applications, :headers_config, :json, default: {}, null: false
+
+ # Add index on domain_pattern for lookup performance
+ add_index :applications, :domain_pattern, unique: true, where: "domain_pattern IS NOT NULL"
+ end
+end
diff --git a/db/migrate/20251104014955_migrate_forward_auth_rules_to_applications.rb b/db/migrate/20251104014955_migrate_forward_auth_rules_to_applications.rb
new file mode 100644
index 0000000..517e952
--- /dev/null
+++ b/db/migrate/20251104014955_migrate_forward_auth_rules_to_applications.rb
@@ -0,0 +1,71 @@
+class MigrateForwardAuthRulesToApplications < ActiveRecord::Migration[8.1]
+ def up
+ # Temporarily define models for migration
+ forward_auth_rule_class = Class.new(ActiveRecord::Base) do
+ self.table_name = "forward_auth_rules"
+ has_many :forward_auth_rule_groups, foreign_key: :forward_auth_rule_id, dependent: :destroy
+ has_many :allowed_groups, through: :forward_auth_rule_groups, source: :group, class_name: "MigrateForwardAuthRulesToApplications::Group"
+ end
+
+ forward_auth_rule_group_class = Class.new(ActiveRecord::Base) do
+ self.table_name = "forward_auth_rule_groups"
+ belongs_to :forward_auth_rule, class_name: "MigrateForwardAuthRulesToApplications::ForwardAuthRule"
+ belongs_to :group, class_name: "MigrateForwardAuthRulesToApplications::Group"
+ end
+
+ group_class = Class.new(ActiveRecord::Base) do
+ self.table_name = "groups"
+ end
+
+ application_class = Class.new(ActiveRecord::Base) do
+ self.table_name = "applications"
+ has_many :application_groups, foreign_key: :application_id, dependent: :destroy
+ end
+
+ application_group_class = Class.new(ActiveRecord::Base) do
+ self.table_name = "application_groups"
+ belongs_to :application, class_name: "MigrateForwardAuthRulesToApplications::Application"
+ belongs_to :group, class_name: "MigrateForwardAuthRulesToApplications::Group"
+ end
+
+ # Assign to constants so we can reference them
+ stub_const("MigrateForwardAuthRulesToApplications::ForwardAuthRule", forward_auth_rule_class)
+ stub_const("MigrateForwardAuthRulesToApplications::ForwardAuthRuleGroup", forward_auth_rule_group_class)
+ stub_const("MigrateForwardAuthRulesToApplications::Group", group_class)
+ stub_const("MigrateForwardAuthRulesToApplications::Application", application_class)
+ stub_const("MigrateForwardAuthRulesToApplications::ApplicationGroup", application_group_class)
+
+ # Migrate each ForwardAuthRule to an Application
+ forward_auth_rule_class.find_each do |rule|
+ # Create Application from ForwardAuthRule
+ app = application_class.create!(
+ name: rule.domain_pattern.titleize,
+ slug: rule.domain_pattern.parameterize.presence || "forward-auth-#{rule.id}",
+ app_type: 'forward_auth',
+ domain_pattern: rule.domain_pattern,
+ headers_config: rule.headers_config || {},
+ active: rule.active
+ )
+
+ # Migrate group associations
+ forward_auth_rule_group_class.where(forward_auth_rule_id: rule.id).find_each do |far_group|
+ application_group_class.create!(
+ application_id: app.id,
+ group_id: far_group.group_id
+ )
+ end
+ end
+ end
+
+ def down
+ # Remove all forward_auth applications created by this migration
+ Application.where(app_type: 'forward_auth').destroy_all
+ end
+
+ private
+
+ def stub_const(name, value)
+ parts = name.split("::")
+ parts[0..-2].inject(Object) { |mod, part| mod.const_get(part) }.const_set(parts.last, value)
+ end
+end
diff --git a/db/migrate/20251104015034_remove_role_related_tables_and_columns.rb b/db/migrate/20251104015034_remove_role_related_tables_and_columns.rb
new file mode 100644
index 0000000..74fe499
--- /dev/null
+++ b/db/migrate/20251104015034_remove_role_related_tables_and_columns.rb
@@ -0,0 +1,15 @@
+class RemoveRoleRelatedTablesAndColumns < ActiveRecord::Migration[8.1]
+ def change
+ # Remove join table first (due to foreign keys)
+ drop_table :user_role_assignments if table_exists?(:user_role_assignments)
+
+ # Remove application_roles table
+ drop_table :application_roles if table_exists?(:application_roles)
+
+ # Remove role-related columns from applications
+ remove_column :applications, :role_mapping_mode, :string if column_exists?(:applications, :role_mapping_mode)
+ remove_column :applications, :role_prefix, :string if column_exists?(:applications, :role_prefix)
+ remove_column :applications, :role_claim_name, :string if column_exists?(:applications, :role_claim_name)
+ remove_column :applications, :managed_permissions, :json if column_exists?(:applications, :managed_permissions)
+ end
+end
diff --git a/db/migrate/20251104015104_remove_forward_auth_tables.rb b/db/migrate/20251104015104_remove_forward_auth_tables.rb
new file mode 100644
index 0000000..aa12b61
--- /dev/null
+++ b/db/migrate/20251104015104_remove_forward_auth_tables.rb
@@ -0,0 +1,9 @@
+class RemoveForwardAuthTables < ActiveRecord::Migration[8.1]
+ def change
+ # Remove join table first (due to foreign keys)
+ drop_table :forward_auth_rule_groups if table_exists?(:forward_auth_rule_groups)
+
+ # Remove forward_auth_rules table
+ drop_table :forward_auth_rules if table_exists?(:forward_auth_rules)
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 43cb777..06bc96d 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2025_10_26_113035) do
+ActiveRecord::Schema[8.1].define(version: 2025_11_04_015104) do
create_table "application_groups", force: :cascade do |t|
t.integer "application_id", null: false
t.datetime "created_at", null: false
@@ -21,19 +21,6 @@ ActiveRecord::Schema[8.1].define(version: 2025_10_26_113035) do
t.index ["group_id"], name: "index_application_groups_on_group_id"
end
- create_table "application_roles", force: :cascade do |t|
- t.boolean "active", default: true
- t.integer "application_id", null: false
- t.datetime "created_at", null: false
- t.text "description"
- t.string "display_name"
- t.string "name", null: false
- t.json "permissions", default: {}
- t.datetime "updated_at", null: false
- t.index ["application_id", "name"], name: "index_application_roles_on_application_id_and_name", unique: true
- t.index ["application_id"], name: "index_application_roles_on_application_id"
- end
-
create_table "applications", force: :cascade do |t|
t.boolean "active", default: true, null: false
t.string "app_type", null: false
@@ -41,40 +28,22 @@ ActiveRecord::Schema[8.1].define(version: 2025_10_26_113035) do
t.string "client_secret_digest"
t.datetime "created_at", null: false
t.text "description"
- t.json "managed_permissions", default: {}
+ t.string "domain_pattern"
+ t.json "headers_config", default: {}, null: false
t.text "metadata"
t.string "name", null: false
t.text "redirect_uris"
- t.string "role_claim_name", default: "roles"
- t.string "role_mapping_mode", default: "disabled", null: false
- t.string "role_prefix"
t.string "slug", null: false
t.datetime "updated_at", null: false
t.index ["active"], name: "index_applications_on_active"
t.index ["client_id"], name: "index_applications_on_client_id", unique: true
+ t.index ["domain_pattern"], name: "index_applications_on_domain_pattern", unique: true, where: "domain_pattern IS NOT NULL"
t.index ["slug"], name: "index_applications_on_slug", unique: true
end
- create_table "forward_auth_rule_groups", force: :cascade do |t|
- t.datetime "created_at", null: false
- t.integer "forward_auth_rule_id", null: false
- t.integer "group_id", null: false
- t.datetime "updated_at", null: false
- t.index ["forward_auth_rule_id"], name: "index_forward_auth_rule_groups_on_forward_auth_rule_id"
- t.index ["group_id"], name: "index_forward_auth_rule_groups_on_group_id"
- end
-
- create_table "forward_auth_rules", force: :cascade do |t|
- t.boolean "active"
- t.datetime "created_at", null: false
- t.string "domain_pattern"
- t.json "headers_config", default: {}, null: false
- t.integer "policy"
- t.datetime "updated_at", null: false
- end
-
create_table "groups", force: :cascade do |t|
t.datetime "created_at", null: false
+ t.json "custom_claims", default: {}, null: false
t.text "description"
t.string "name", null: false
t.datetime "updated_at", null: false
@@ -152,22 +121,11 @@ ActiveRecord::Schema[8.1].define(version: 2025_10_26_113035) do
t.index ["user_id"], name: "index_user_groups_on_user_id"
end
- create_table "user_role_assignments", force: :cascade do |t|
- t.integer "application_role_id", null: false
- t.datetime "created_at", null: false
- t.json "metadata", default: {}
- t.string "source", default: "oidc"
- t.datetime "updated_at", null: false
- t.integer "user_id", null: false
- t.index ["application_role_id"], name: "index_user_role_assignments_on_application_role_id"
- t.index ["user_id", "application_role_id"], name: "index_user_role_assignments_on_user_id_and_application_role_id", unique: true
- t.index ["user_id"], name: "index_user_role_assignments_on_user_id"
- end
-
create_table "users", force: :cascade do |t|
t.boolean "admin", default: false, null: false
t.text "backup_codes"
t.datetime "created_at", null: false
+ t.json "custom_claims", default: {}, null: false
t.string "email_address", null: false
t.datetime "last_sign_in_at"
t.string "password_digest", null: false
@@ -181,9 +139,6 @@ ActiveRecord::Schema[8.1].define(version: 2025_10_26_113035) do
add_foreign_key "application_groups", "applications"
add_foreign_key "application_groups", "groups"
- add_foreign_key "application_roles", "applications"
- add_foreign_key "forward_auth_rule_groups", "forward_auth_rules"
- add_foreign_key "forward_auth_rule_groups", "groups"
add_foreign_key "oidc_access_tokens", "applications"
add_foreign_key "oidc_access_tokens", "users"
add_foreign_key "oidc_authorization_codes", "applications"
@@ -193,6 +148,4 @@ ActiveRecord::Schema[8.1].define(version: 2025_10_26_113035) do
add_foreign_key "sessions", "users"
add_foreign_key "user_groups", "groups"
add_foreign_key "user_groups", "users"
- add_foreign_key "user_role_assignments", "application_roles"
- add_foreign_key "user_role_assignments", "users"
end
diff --git a/test/fixtures/forward_auth_rules.yml b/test/fixtures/forward_auth_rules.yml
deleted file mode 100644
index 50ae278..0000000
--- a/test/fixtures/forward_auth_rules.yml
+++ /dev/null
@@ -1,11 +0,0 @@
-# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
-
-one:
- domain_pattern: MyString
- policy: 1
- active: false
-
-two:
- domain_pattern: MyString
- policy: 1
- active: false
diff --git a/test/integration/oidc_role_mapping_test.rb b/test/integration/oidc_role_mapping_test.rb
deleted file mode 100644
index a21de87..0000000
--- a/test/integration/oidc_role_mapping_test.rb
+++ /dev/null
@@ -1,210 +0,0 @@
-require "test_helper"
-
-class OidcRoleMappingTest < ActionDispatch::IntegrationTest
- def setup
- @application = applications(:kavita_app)
- @user = users(:alice)
-
- # Set a known client secret for testing
- @test_client_secret = "test_secret_for_testing_only"
- @application.client_secret = @test_client_secret
- @application.save!
-
- @application.update!(
- role_mapping_mode: "oidc_managed",
- role_claim_name: "roles"
- )
-
- @admin_role = @application.application_roles.create!(
- name: "admin",
- display_name: "Administrator"
- )
- @editor_role = @application.application_roles.create!(
- name: "editor",
- display_name: "Editor"
- )
-
- sign_in @user
- end
-
- test "should include roles in JWT tokens" do
- # Assign roles to user
- @application.assign_role_to_user!(@user, "admin", source: 'oidc')
- @application.assign_role_to_user!(@user, "editor", source: 'oidc')
-
- # Get authorization code
- post oauth_authorize_path, params: {
- client_id: @application.client_id,
- response_type: "code",
- redirect_uri: "https://example.com/callback",
- scope: "openid profile email",
- state: "test-state",
- nonce: "test-nonce"
- }
-
- follow_redirect!
- post oauth_consent_path, params: {
- consent: "approve",
- client_id: @application.client_id,
- redirect_uri: "https://example.com/callback",
- scope: "openid profile email",
- state: "test-state"
- }
-
- assert_response :redirect
- authorization_code = extract_code_from_redirect(response.location)
-
- # Exchange code for token
- post oauth_token_path, params: {
- grant_type: "authorization_code",
- code: authorization_code,
- redirect_uri: "https://example.com/callback",
- client_id: @application.client_id,
- client_secret: @test_client_secret
- }
-
- assert_response :success
- token_response = JSON.parse(response.body)
- id_token = token_response["id_token"]
-
- # Decode and verify ID token contains roles
- decoded_token = JWT.decode(id_token, nil, false).first
- assert_includes decoded_token["roles"], "admin"
- assert_includes decoded_token["roles"], "editor"
- end
-
- test "should filter roles by prefix" do
- @application.update!(role_prefix: "app-")
- @admin_role.update!(name: "app-admin")
- @editor_role.update!(name: "external-editor") # Should be filtered out
-
- @application.assign_role_to_user!(@user, "app-admin", source: 'oidc')
- @application.assign_role_to_user!(@user, "external-editor", source: 'oidc')
-
- # Get token
- post oauth_authorize_path, params: {
- client_id: @application.client_id,
- response_type: "code",
- redirect_uri: "https://example.com/callback",
- scope: "openid profile email",
- state: "test-state"
- }
-
- follow_redirect!
- post oauth_consent_path, params: {
- consent: "approve",
- client_id: @application.client_id,
- redirect_uri: "https://example.com/callback",
- scope: "openid profile email",
- state: "test-state"
- }
-
- authorization_code = extract_code_from_redirect(response.location)
-
- post oauth_token_path, params: {
- grant_type: "authorization_code",
- code: authorization_code,
- redirect_uri: "https://example.com/callback",
- client_id: @application.client_id,
- client_secret: @test_client_secret
- }
-
- token_response = JSON.parse(response.body)
- id_token = token_response["id_token"]
- decoded_token = JWT.decode(id_token, nil, false).first
-
- assert_includes decoded_token["roles"], "app-admin"
- assert_not_includes decoded_token["roles"], "external-editor"
- end
-
- test "should include role permissions when configured" do
- @application.update!(managed_permissions: { "include_permissions" => true })
- @admin_role.update!(permissions: { "read" => true, "write" => true, "delete" => true })
-
- @application.assign_role_to_user!(@user, "admin", source: 'oidc')
-
- # Get token and check for role permissions
- post oauth_authorize_path, params: {
- client_id: @application.client_id,
- response_type: "code",
- redirect_uri: "https://example.com/callback",
- scope: "openid profile email",
- state: "test-state"
- }
-
- follow_redirect!
- post oauth_consent_path, params: {
- consent: "approve",
- client_id: @application.client_id,
- redirect_uri: "https://example.com/callback",
- scope: "openid profile email",
- state: "test-state"
- }
-
- authorization_code = extract_code_from_redirect(response.location)
-
- post oauth_token_path, params: {
- grant_type: "authorization_code",
- code: authorization_code,
- redirect_uri: "https://example.com/callback",
- client_id: @application.client_id,
- client_secret: @test_client_secret
- }
-
- token_response = JSON.parse(response.body)
- id_token = token_response["id_token"]
- decoded_token = JWT.decode(id_token, nil, false).first
-
- assert decoded_token["role_permissions"].present?
- role_permissions = decoded_token["role_permissions"].find { |rp| rp["name"] == "admin" }
- assert_equal({ "read" => true, "write" => true, "delete" => true }, role_permissions["permissions"])
- end
-
- test "should use custom role claim name" do
- @application.update!(role_claim_name: "user_roles")
- @application.assign_role_to_user!(@user, "admin", source: 'oidc')
-
- # Get token
- post oauth_authorize_path, params: {
- client_id: @application.client_id,
- response_type: "code",
- redirect_uri: "https://example.com/callback",
- scope: "openid profile email",
- state: "test-state"
- }
-
- follow_redirect!
- post oauth_consent_path, params: {
- consent: "approve",
- client_id: @application.client_id,
- redirect_uri: "https://example.com/callback",
- scope: "openid profile email",
- state: "test-state"
- }
-
- authorization_code = extract_code_from_redirect(response.location)
-
- post oauth_token_path, params: {
- grant_type: "authorization_code",
- code: authorization_code,
- redirect_uri: "https://example.com/callback",
- client_id: @application.client_id,
- client_secret: @test_client_secret
- }
-
- token_response = JSON.parse(response.body)
- id_token = token_response["id_token"]
- decoded_token = JWT.decode(id_token, nil, false).first
-
- assert_nil decoded_token["roles"]
- assert_includes decoded_token["user_roles"], "admin"
- end
-
- private
-
- def extract_code_from_redirect(redirect_url)
- uri = URI.parse(redirect_url)
- query_params = CGI.parse(uri.query)
- query_params["code"]&.first
- end
-end
\ No newline at end of file
diff --git a/test/models/application_role_test.rb b/test/models/application_role_test.rb
deleted file mode 100644
index 10c6f74..0000000
--- a/test/models/application_role_test.rb
+++ /dev/null
@@ -1,86 +0,0 @@
-require "test_helper"
-
-class ApplicationRoleTest < ActiveSupport::TestCase
- def setup
- @application = applications(:kavita_app)
- @role = @application.application_roles.create!(
- name: "admin",
- display_name: "Administrator",
- description: "Full access to all features"
- )
- end
-
- test "should be valid" do
- assert @role.valid?
- end
-
- test "should require name" do
- @role.name = ""
- assert_not @role.valid?
- assert_includes @role.errors[:name], "can't be blank"
- end
-
- test "should require display_name" do
- @role.display_name = ""
- assert_not @role.valid?
- assert_includes @role.errors[:display_name], "can't be blank"
- end
-
- test "should enforce unique role name per application" do
- duplicate_role = @application.application_roles.build(
- name: @role.name,
- display_name: "Another Admin"
- )
- assert_not duplicate_role.valid?
- assert_includes duplicate_role.errors[:name], "has already been taken"
- end
-
- test "should allow same role name in different applications" do
- other_app = Application.create!(
- name: "Other App",
- slug: "other-app",
- app_type: "oidc"
- )
- other_role = other_app.application_roles.build(
- name: @role.name,
- display_name: "Other Admin"
- )
- assert other_role.valid?
- end
-
- test "should track user assignments" do
- user = users(:alice)
- assert_not @role.user_has_role?(user)
-
- @role.assign_to_user!(user)
- assert @role.user_has_role?(user)
- assert @role.users.include?(user)
- end
-
- test "should handle role removal" do
- user = users(:alice)
- @role.assign_to_user!(user)
- assert @role.user_has_role?(user)
-
- @role.remove_from_user!(user)
- assert_not @role.user_has_role?(user)
- assert_not @role.users.include?(user)
- end
-
- test "should default to active" do
- new_role = @application.application_roles.build(
- name: "member",
- display_name: "Member"
- )
- assert new_role.active?
- end
-
- test "should support default permissions" do
- role_with_permissions = @application.application_roles.create!(
- name: "editor",
- display_name: "Editor",
- permissions: { "read" => true, "write" => true, "delete" => false }
- )
- assert_equal({ "read" => true, "write" => true, "delete" => false }, role_with_permissions.permissions)
- end
-end
\ No newline at end of file
diff --git a/test/models/forward_auth_rule_test.rb b/test/models/forward_auth_rule_test.rb
deleted file mode 100644
index 1923c15..0000000
--- a/test/models/forward_auth_rule_test.rb
+++ /dev/null
@@ -1,395 +0,0 @@
-require "test_helper"
-
-class ForwardAuthRuleTest < ActiveSupport::TestCase
- def setup
- @rule = ForwardAuthRule.new(
- domain_pattern: "*.example.com",
- active: true
- )
- end
-
- test "should be valid with valid attributes" do
- assert @rule.valid?
- end
-
- test "should require domain_pattern" do
- @rule.domain_pattern = ""
- assert_not @rule.valid?
- assert_includes @rule.errors[:domain_pattern], "can't be blank"
- end
-
- test "should require active to be boolean" do
- @rule.active = nil
- assert_not @rule.valid?
- assert_includes @rule.errors[:active], "is not included in the list"
- end
-
- test "should normalize domain_pattern to lowercase" do
- @rule.domain_pattern = "*.EXAMPLE.COM"
- @rule.save!
- assert_equal "*.example.com", @rule.reload.domain_pattern
- end
-
- test "should enforce unique domain_pattern" do
- @rule.save!
- duplicate = ForwardAuthRule.new(
- domain_pattern: "*.example.com",
- active: true
- )
- assert_not duplicate.valid?
- assert_includes duplicate.errors[:domain_pattern], "has already been taken"
- end
-
- test "should match domain patterns correctly" do
- @rule.save!
-
- assert @rule.matches_domain?("app.example.com")
- assert @rule.matches_domain?("api.example.com")
- assert @rule.matches_domain?("sub.app.example.com")
- assert_not @rule.matches_domain?("example.org")
- assert_not @rule.matches_domain?("otherexample.com")
- end
-
- test "should handle exact domain matches" do
- @rule.domain_pattern = "api.example.com"
- @rule.save!
-
- assert @rule.matches_domain?("api.example.com")
- assert_not @rule.matches_domain?("app.example.com")
- assert_not @rule.matches_domain?("sub.api.example.com")
- end
-
- test "policy_for_user should return bypass when no groups assigned" do
- user = users(:one)
- @rule.save!
-
- assert_equal "bypass", @rule.policy_for_user(user)
- end
-
- test "policy_for_user should return deny for inactive rule" do
- user = users(:one)
- @rule.active = false
- @rule.save!
-
- assert_equal "deny", @rule.policy_for_user(user)
- end
-
- test "policy_for_user should return deny for inactive user" do
- user = users(:one)
- user.update!(active: false)
- @rule.save!
-
- assert_equal "deny", @rule.policy_for_user(user)
- end
-
- test "policy_for_user should return correct policy based on user groups and TOTP" do
- group = groups(:one)
- user_with_totp = users(:two)
- user_without_totp = users(:one)
-
- user_with_totp.totp_secret = "test_secret"
- user_with_totp.save!
-
- @rule.allowed_groups << group
- user_with_totp.groups << group
- user_without_totp.groups << group
- @rule.save!
-
- assert_equal "two_factor", @rule.policy_for_user(user_with_totp)
- assert_equal "one_factor", @rule.policy_for_user(user_without_totp)
- end
-
- test "user_allowed? should return true when no groups assigned" do
- user = users(:one)
- @rule.save!
-
- assert @rule.user_allowed?(user)
- end
-
- test "user_allowed? should return true when user in allowed groups" do
- group = groups(:one)
- user = users(:one)
- user.groups << group
- @rule.allowed_groups << group
- @rule.save!
-
- assert @rule.user_allowed?(user)
- end
-
- test "user_allowed? should return false when user not in allowed groups" do
- group = groups(:one)
- user = users(:one)
- @rule.allowed_groups << group
- @rule.save!
-
- assert_not @rule.user_allowed?(user)
- end
-
- # Header Configuration Tests
- test "effective_headers should return default headers when no custom config" do
- @rule.save!
-
- expected = ForwardAuthRule::DEFAULT_HEADERS
- assert_equal expected, @rule.effective_headers
- end
-
- test "effective_headers should merge custom headers with defaults" do
- @rule.save!
- @rule.update!(headers_config: { user: "X-Forwarded-User", email: "X-Forwarded-Email" })
-
- expected = ForwardAuthRule::DEFAULT_HEADERS.merge(
- user: "X-Forwarded-User",
- email: "X-Forwarded-Email"
- )
- assert_equal expected, @rule.effective_headers
- end
-
- test "headers_for_user should generate correct headers for user with groups" do
- group = groups(:one)
- user = users(:one)
- user.groups << group
- @rule.save!
-
- headers = @rule.headers_for_user(user)
-
- assert_equal user.email_address, headers["X-Remote-User"]
- assert_equal user.email_address, headers["X-Remote-Email"]
- assert_equal user.email_address, headers["X-Remote-Name"]
- assert_equal group.name, headers["X-Remote-Groups"]
- assert_equal "true", headers["X-Remote-Admin"]
- end
-
- test "headers_for_user should generate correct headers for user without groups" do
- user = users(:one)
- @rule.save!
-
- headers = @rule.headers_for_user(user)
-
- assert_equal user.email_address, headers["X-Remote-User"]
- assert_equal user.email_address, headers["X-Remote-Email"]
- assert_equal user.email_address, headers["X-Remote-Name"]
- assert_nil headers["X-Remote-Groups"] # No groups, no header
- assert_equal "true", headers["X-Remote-Admin"]
- end
-
- test "headers_for_user should work with custom headers" do
- user = users(:one)
- @rule.update!(headers_config: {
- user: "X-Forwarded-User",
- groups: "X-Custom-Groups"
- })
-
- headers = @rule.headers_for_user(user)
-
- assert_equal user.email_address, headers["X-Forwarded-User"]
- assert_nil headers["X-Remote-User"] # Should be overridden
- assert_equal user.email_address, headers["X-Remote-Email"] # Default preserved
- assert_nil headers["X-Custom-Groups"] # User has no groups
- end
-
- test "headers_for_user should return empty hash when all headers disabled" do
- user = users(:one)
- @rule.update!(headers_config: {
- user: "",
- email: "",
- name: "",
- groups: "",
- admin: ""
- })
-
- headers = @rule.headers_for_user(user)
- assert_empty headers
- end
-
- test "headers_disabled? should correctly identify disabled headers" do
- @rule.save!
- assert_not @rule.headers_disabled?
-
- @rule.update!(headers_config: { user: "X-Custom-User" })
- assert_not @rule.headers_disabled?
-
- @rule.update!(headers_config: { user: "", email: "", name: "", groups: "", admin: "" })
- assert @rule.headers_disabled?
- end
-
- # Additional Domain Pattern Tests
- test "matches_domain? should handle complex patterns" do
- @rule.save!
-
- # Test multiple wildcards
- @rule.update!(domain_pattern: "*.*.example.com")
- assert @rule.matches_domain?("app.dev.example.com")
- assert @rule.matches_domain?("api.staging.example.com")
- assert_not @rule.matches_domain?("example.com")
- assert_not @rule.matches_domain?("app.example.org")
-
- # Test exact domain with dots
- @rule.update!(domain_pattern: "api.v2.example.com")
- assert @rule.matches_domain?("api.v2.example.com")
- assert_not @rule.matches_domain?("api.v3.example.com")
- assert_not @rule.matches_domain?("v2.api.example.com")
- end
-
- test "matches_domain? should handle case insensitivity" do
- @rule.update!(domain_pattern: "*.EXAMPLE.COM")
- @rule.save!
-
- assert @rule.matches_domain?("app.example.com")
- assert @rule.matches_domain?("APP.EXAMPLE.COM")
- assert @rule.matches_domain?("App.Example.Com")
- end
-
- test "matches_domain? should handle empty and nil domains" do
- @rule.save!
-
- assert_not @rule.matches_domain?("")
- assert_not @rule.matches_domain?(nil)
- end
-
- # Advanced Header Configuration Tests
- test "headers_for_user should handle partial header configuration" do
- user = users(:one)
- user.groups << groups(:one)
- @rule.update!(headers_config: {
- user: "X-Custom-User",
- email: "", # Disabled
- groups: "X-Custom-Groups"
- })
- @rule.save!
-
- headers = @rule.headers_for_user(user)
-
- # Should include custom user header
- assert_equal "X-Custom-User", headers.keys.find { |k| k.include?("User") }
- assert_equal user.email_address, headers["X-Custom-User"]
-
- # Should include default email header (not overridden)
- assert_equal "X-Remote-Email", headers.keys.find { |k| k.include?("Email") }
- assert_equal user.email_address, headers["X-Remote-Email"]
-
- # Should include custom groups header
- assert_equal "X-Custom-Groups", headers.keys.find { |k| k.include?("Groups") }
- assert_equal groups(:one).name, headers["X-Custom-Groups"]
-
- # Should include default name header (not overridden)
- assert_equal "X-Remote-Name", headers.keys.find { |k| k.include?("Name") }
- end
-
- test "headers_for_user should handle user without groups when groups header configured" do
- user = users(:one)
- user.groups.clear # No groups
- @rule.update!(headers_config: { groups: "X-Custom-Groups" })
- @rule.save!
-
- headers = @rule.headers_for_user(user)
-
- # Should not include groups header for user with no groups
- assert_nil headers["X-Custom-Groups"]
- assert_nil headers["X-Remote-Groups"]
- end
-
- test "headers_for_user should handle non-admin user correctly" do
- user = users(:one)
- # Ensure user is not admin
- user.update!(admin: false)
- @rule.save!
-
- headers = @rule.headers_for_user(user)
-
- assert_equal "false", headers["X-Remote-Admin"]
- end
-
- test "headers_for_user should work with nil headers_config" do
- user = users(:one)
- @rule.update!(headers_config: nil)
- @rule.save!
-
- headers = @rule.headers_for_user(user)
-
- # Should use default headers
- assert_equal "X-Remote-User", headers.keys.find { |k| k.include?("User") }
- assert_equal user.email_address, headers["X-Remote-User"]
- end
-
- test "effective_headers should handle symbol keys in headers_config" do
- @rule.update!(headers_config: { user: "X-Symbol-User", email: "X-Symbol-Email" })
- @rule.save!
-
- effective = @rule.effective_headers
-
- assert_equal "X-Symbol-User", effective[:user]
- assert_equal "X-Symbol-Email", effective[:email]
- assert_equal "X-Remote-Name", effective[:name] # Default
- end
-
- test "effective_headers should handle string keys in headers_config" do
- @rule.update!(headers_config: { "user" => "X-String-User", "email" => "X-String-Email" })
- @rule.save!
-
- effective = @rule.effective_headers
-
- assert_equal "X-String-User", effective[:user]
- assert_equal "X-String-Email", effective[:email]
- assert_equal "X-Remote-Name", effective[:name] # Default
- end
-
- # Policy and Access Control Tests
- test "policy_for_user should handle user with TOTP enabled" do
- user = users(:one)
- user.update!(totp_secret: "test_secret")
- @rule.allowed_groups << groups(:one)
- user.groups << groups(:one)
- @rule.save!
-
- policy = @rule.policy_for_user(user)
- assert_equal "two_factor", policy
- end
-
- test "policy_for_user should handle user without TOTP" do
- user = users(:one)
- user.update!(totp_secret: nil)
- @rule.allowed_groups << groups(:one)
- user.groups << groups(:one)
- @rule.save!
-
- policy = @rule.policy_for_user(user)
- assert_equal "one_factor", policy
- end
-
- test "policy_for_user should handle user with multiple groups" do
- user = users(:one)
- group1 = groups(:one)
- group2 = groups(:two)
- @rule.allowed_groups << group1
- @rule.allowed_groups << group2
- user.groups << group1
- @rule.save!
-
- policy = @rule.policy_for_user(user)
- assert_equal "one_factor", policy
- end
-
- test "user_allowed? should handle user with multiple groups, one allowed" do
- user = users(:one)
- allowed_group = groups(:one)
- other_group = groups(:two)
- @rule.allowed_groups << allowed_group
- user.groups << allowed_group
- user.groups << other_group
- @rule.save!
-
- assert @rule.user_allowed?(user)
- end
-
- test "user_allowed? should handle user with multiple groups, none allowed" do
- user = users(:one)
- group1 = groups(:one)
- group2 = groups(:two)
- # Don't add any groups to allowed_groups
- user.groups << group1
- user.groups << group2
- @rule.save!
-
- assert_not @rule.user_allowed?(user)
- end
-end
diff --git a/test/models/user_role_assignment_test.rb b/test/models/user_role_assignment_test.rb
deleted file mode 100644
index 86a52b3..0000000
--- a/test/models/user_role_assignment_test.rb
+++ /dev/null
@@ -1,87 +0,0 @@
-require "test_helper"
-
-class UserRoleAssignmentTest < ActiveSupport::TestCase
- def setup
- @application = applications(:kavita_app)
- @role = @application.application_roles.create!(
- name: "admin",
- display_name: "Administrator"
- )
- @user = users(:alice)
- @assignment = UserRoleAssignment.create!(
- user: @user,
- application_role: @role
- )
- end
-
- test "should be valid" do
- assert @assignment.valid?
- end
-
- test "should enforce unique user-role combination" do
- duplicate_assignment = UserRoleAssignment.new(
- user: @user,
- application_role: @role
- )
- assert_not duplicate_assignment.valid?
- assert_includes duplicate_assignment.errors[:user], "has already been taken"
- end
-
- test "should allow same user with different roles" do
- other_role = @application.application_roles.create!(
- name: "editor",
- display_name: "Editor"
- )
- other_assignment = UserRoleAssignment.new(
- user: @user,
- application_role: other_role
- )
- assert other_assignment.valid?
- end
-
- test "should allow same role for different users" do
- other_user = users(:bob)
- other_assignment = UserRoleAssignment.new(
- user: other_user,
- application_role: @role
- )
- assert other_assignment.valid?
- end
-
- test "should validate source" do
- @assignment.source = "invalid_source"
- assert_not @assignment.valid?
- assert_includes @assignment.errors[:source], "is not included in the list"
- end
-
- test "should support valid sources" do %w[oidc manual group_sync].each do |source|
- @assignment.source = source
- assert @assignment.valid?, "Source '#{source}' should be valid"
- end
- end
-
- test "should default to oidc source" do
- new_assignment = UserRoleAssignment.new(
- user: @user,
- application_role: @role
- )
- assert_equal "oidc", new_assignment.source
- end
-
- test "should support metadata" do
- metadata = { "synced_at" => Time.current, "external_source" => "authentik" }
- @assignment.metadata = metadata
- @assignment.save
- assert_equal metadata, @assignment.reload.metadata
- end
-
- test "should identify oidc managed assignments" do
- @assignment.source = "oidc"
- assert @assignment.sync_from_oidc?
- end
-
- test "should not identify manually managed assignments as oidc" do
- @assignment.source = "manual"
- assert_not @assignment.sync_from_oidc?
- end
-end
\ No newline at end of file
diff --git a/test/services/oidc_jwt_service_test.rb b/test/services/oidc_jwt_service_test.rb
index 844c264..9a5621f 100644
--- a/test/services/oidc_jwt_service_test.rb
+++ b/test/services/oidc_jwt_service_test.rb
@@ -207,5 +207,4 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
end
assert_match /no key found/, error.message, "Should warn about missing private key"
end
- end
end
\ No newline at end of file
diff --git a/test/services/role_mapping_engine_test.rb b/test/services/role_mapping_engine_test.rb
deleted file mode 100644
index 62ce913..0000000
--- a/test/services/role_mapping_engine_test.rb
+++ /dev/null
@@ -1,163 +0,0 @@
-require "test_helper"
-
-class RoleMappingEngineTest < ActiveSupport::TestCase
- def setup
- @application = applications(:kavita_app)
- @user = users(:alice)
- @application.update!(
- role_mapping_mode: "oidc_managed",
- role_claim_name: "roles"
- )
-
- @admin_role = @application.application_roles.create!(
- name: "admin",
- display_name: "Administrator"
- )
- @editor_role = @application.application_roles.create!(
- name: "editor",
- display_name: "Editor"
- )
- end
-
- test "should sync user roles from claims" do
- claims = { "roles" => ["admin", "editor"] }
-
- RoleMappingEngine.sync_user_roles!(@user, @application, claims)
-
- assert @application.user_has_role?(@user, "admin")
- assert @application.user_has_role?(@user, "editor")
- end
-
- test "should remove roles not present in claims for oidc managed" do
- # Assign initial roles
- @application.assign_role_to_user!(@user, "admin", source: 'oidc')
- @application.assign_role_to_user!(@user, "editor", source: 'oidc')
-
- # Sync with only admin role
- claims = { "roles" => ["admin"] }
- RoleMappingEngine.sync_user_roles!(@user, @application, claims)
-
- assert @application.user_has_role?(@user, "admin")
- assert_not @application.user_has_role?(@user, "editor")
- end
-
- test "should handle hybrid mode role sync" do
- @application.update!(role_mapping_mode: "hybrid")
-
- # Assign manual role first
- @application.assign_role_to_user!(@user, "editor", source: 'manual')
-
- # Sync with admin role from OIDC
- claims = { "roles" => ["admin"] }
- RoleMappingEngine.sync_user_roles!(@user, @application, claims)
-
- assert @application.user_has_role?(@user, "admin")
- assert @application.user_has_role?(@user, "editor") # Manual role preserved
- end
-
- test "should filter roles by prefix" do
- @application.update!(role_prefix: "app-")
- @admin_role.update!(name: "app-admin")
- @editor_role.update!(name: "app-editor")
-
- # Create non-matching role
- external_role = @application.application_roles.create!(
- name: "external-role",
- display_name: "External"
- )
-
- claims = { "roles" => ["app-admin", "app-editor", "external-role"] }
- RoleMappingEngine.sync_user_roles!(@user, @application, claims)
-
- assert @application.user_has_role?(@user, "app-admin")
- assert @application.user_has_role?(@user, "app-editor")
- assert_not @application.user_has_role?(@user, "external-role")
- end
-
- test "should handle different claim names" do
- @application.update!(role_claim_name: "groups")
- claims = { "groups" => ["admin", "editor"] }
-
- RoleMappingEngine.sync_user_roles!(@user, @application, claims)
-
- assert @application.user_has_role?(@user, "admin")
- assert @application.user_has_role?(@user, "editor")
- end
-
- test "should handle microsoft role claim format" do
- microsoft_claim = "http://schemas.microsoft.com/ws/2008/06/identity/claims/role"
- claims = { microsoft_claim => ["admin", "editor"] }
-
- RoleMappingEngine.sync_user_roles!(@user, @application, claims)
-
- assert @application.user_has_role?(@user, "admin")
- assert @application.user_has_role?(@user, "editor")
- end
-
- test "should determine user access based on roles" do
- # OIDC managed mode - user needs roles to access
- claims = { "roles" => ["admin"] }
- assert RoleMappingEngine.user_allowed_with_roles?(@user, @application, claims)
-
- # No roles should deny access
- empty_claims = { "roles" => [] }
- assert_not RoleMappingEngine.user_allowed_with_roles?(@user, @application, empty_claims)
- end
-
- test "should handle hybrid mode access control" do
- @application.update!(role_mapping_mode: "hybrid")
-
- # User with group access should be allowed
- group_access = @application.user_allowed?(@user)
- assert RoleMappingEngine.user_allowed_with_roles?(@user, @application)
-
- # User with role access should be allowed
- claims = { "roles" => ["admin"] }
- assert RoleMappingEngine.user_allowed_with_roles?(@user, @application, claims)
-
- # User without either should be denied
- empty_claims = { "roles" => [] }
- result = RoleMappingEngine.user_allowed_with_roles?(@user, @application, empty_claims)
- # Should be allowed if group access exists, otherwise denied
- assert_equal group_access, result
- end
-
- test "should map external roles to internal roles" do
- external_roles = ["admin", "editor", "unknown-role"]
-
- mapped_roles = RoleMappingEngine.map_external_to_internal_roles(@application, external_roles)
-
- assert_includes mapped_roles, "admin"
- assert_includes mapped_roles, "editor"
- assert_not_includes mapped_roles, "unknown-role"
- end
-
- test "should extract roles from various claim formats" do
- # Array format
- claims_array = { "roles" => ["admin", "editor"] }
- roles = RoleMappingEngine.send(:extract_roles_from_claims, @application, claims_array)
- assert_equal ["admin", "editor"], roles
-
- # String format
- claims_string = { "roles" => "admin" }
- roles = RoleMappingEngine.send(:extract_roles_from_claims, @application, claims_string)
- assert_equal ["admin"], roles
-
- # No roles
- claims_empty = { "other_claim" => "value" }
- roles = RoleMappingEngine.send(:extract_roles_from_claims, @application, claims_empty)
- assert_equal [], roles
- end
-
- test "should handle disabled role mapping" do
- @application.update!(role_mapping_mode: "disabled")
- claims = { "roles" => ["admin"] }
-
- # Should not sync roles when disabled
- RoleMappingEngine.sync_user_roles!(@user, @application, claims)
- assert_not @application.user_has_role?(@user, "admin")
-
- # Should fall back to regular access control
- assert RoleMappingEngine.user_allowed_with_roles?(@user, @application, claims)
- end
-end
\ No newline at end of file
diff --git a/test/simple_role_test.rb b/test/simple_role_test.rb
deleted file mode 100644
index fe88d4d..0000000
--- a/test/simple_role_test.rb
+++ /dev/null
@@ -1,96 +0,0 @@
-#!/usr/bin/env ruby
-
-# Simple test script to verify role mapping functionality
-# Run with: ruby test/simple_role_test.rb
-
-require_relative "../config/environment"
-
-puts "🧪 Testing OIDC Role Mapping functionality..."
-
-begin
- # Create test user
- user = User.create!(
- email_address: "test#{Time.current.to_i}@example.com",
- password: "password123",
- admin: false,
- status: :active
- )
- puts "✅ Created test user: #{user.email_address}"
-
- # Create test application
- application = Application.create!(
- name: "Test Role App",
- slug: "test-role-app-#{Time.current.to_i}",
- app_type: "oidc",
- role_mapping_mode: "oidc_managed"
- )
- puts "✅ Created test application: #{application.name}"
-
- # Create role
- role = application.application_roles.create!(
- name: "admin",
- display_name: "Administrator",
- description: "Full access role"
- )
- puts "✅ Created role: #{role.name}"
-
- # Test role assignment
- application.assign_role_to_user!(user, "admin", source: 'manual')
- puts "✅ Assigned role to user"
-
- # Verify role assignment
- unless application.user_has_role?(user, "admin")
- raise "Role should be assigned to user"
- end
- puts "✅ Verified role assignment"
-
- # Test role mapping engine
- claims = { "roles" => ["admin", "editor"] }
- RoleMappingEngine.sync_user_roles!(user, application, claims)
- puts "✅ Synced roles from OIDC claims"
-
- # Test JWT generation with roles
- token = OidcJwtService.generate_id_token(user, application)
- decoded = JWT.decode(token, nil, false).first
- unless decoded["roles"]&.include?("admin")
- raise "JWT should contain roles"
- end
- puts "✅ JWT includes roles claim"
-
- # Test custom claim name
- application.update!(role_claim_name: "user_roles")
- token = OidcJwtService.generate_id_token(user, application)
- decoded = JWT.decode(token, nil, false).first
- unless decoded["user_roles"]&.include?("admin")
- raise "JWT should use custom claim name"
- end
- puts "✅ Custom claim name works"
-
- # Test role prefix filtering
- application.update!(role_prefix: "app-")
- role.update!(name: "app-admin")
- application.assign_role_to_user!(user, "app-admin", source: 'manual')
-
- claims = { "roles" => ["app-admin", "external-role"] }
- RoleMappingEngine.sync_user_roles!(user, application, claims)
- unless application.user_has_role?(user, "app-admin")
- raise "Prefixed role should be assigned"
- end
- if application.user_has_role?(user, "external-role")
- raise "Non-prefixed role should be filtered"
- end
- puts "✅ Role prefix filtering works"
-
- # Cleanup
- user.destroy
- application.destroy
- puts "🧹 Cleaned up test data"
-
- puts "\n🎉 All tests passed! OIDC Role Mapping is working correctly."
-
-rescue => e
- puts "❌ Test failed: #{e.message}"
- puts e.backtrace.first(5)
- exit 1
-end
-
diff --git a/test/unit/role_mapping_test.rb b/test/unit/role_mapping_test.rb
deleted file mode 100644
index f8fb53f..0000000
--- a/test/unit/role_mapping_test.rb
+++ /dev/null
@@ -1,111 +0,0 @@
-require "test_helper"
-
-class RoleMappingTest < ActiveSupport::TestCase
- self.use_transactional_tests = true
-
- # Don't load any fixtures
- def self.fixtures :all
- # Disable fixtures
- end
- # Test without fixtures for simplicity
- def setup
- @user = User.create!(
- email_address: "test@example.com",
- password: "password123",
- admin: false,
- status: :active
- )
-
- @application = Application.create!(
- name: "Test App",
- slug: "test-app",
- app_type: "oidc"
- )
-
- @admin_role = @application.application_roles.create!(
- name: "admin",
- display_name: "Administrator",
- description: "Full access user"
- )
- end
-
- def teardown
- UserRoleAssignment.delete_all
- ApplicationRole.delete_all
- Application.delete_all
- User.delete_all
- end
-
- test "should create application role" do
- assert @admin_role.valid?
- assert @admin_role.active?
- assert_equal "Administrator", @admin_role.display_name
- end
-
- test "should assign role to user" do
- assert_not @application.user_has_role?(@user, "admin")
-
- @application.assign_role_to_user!(@user, "admin", source: 'manual')
-
- assert @application.user_has_role?(@user, "admin")
- assert @admin_role.user_has_role?(@user)
- end
-
- test "should remove role from user" do
- @application.assign_role_to_user!(@user, "admin", source: 'manual')
- assert @application.user_has_role?(@user, "admin")
-
- @application.remove_role_from_user!(@user, "admin")
- assert_not @application.user_has_role?(@user, "admin")
- end
-
- test "should support role mapping modes" do
- assert_equal "disabled", @application.role_mapping_mode
-
- @application.update!(role_mapping_mode: "oidc_managed")
- assert @application.role_mapping_enabled?
- assert @application.oidc_managed_roles?
-
- @application.update!(role_mapping_mode: "hybrid")
- assert @application.hybrid_roles?
- end
-
- test "should sync roles from OIDC claims" do
- @application.update!(role_mapping_mode: "oidc_managed")
-
- claims = { "roles" => ["admin"] }
- RoleMappingEngine.sync_user_roles!(@user, @application, claims)
-
- assert @application.user_has_role?(@user, "admin")
- end
-
- test "should filter roles by prefix" do
- @application.update!(role_prefix: "app-")
- @admin_role.update!(name: "app-admin")
-
- claims = { "roles" => ["app-admin", "external-role"] }
- RoleMappingEngine.sync_user_roles!(@user, @application, claims)
-
- assert @application.user_has_role?(@user, "app-admin")
- end
-
- test "should include roles in JWT tokens" do
- @application.assign_role_to_user!(@user, "admin", source: 'oidc')
-
- token = OidcJwtService.generate_id_token(@user, @application)
- decoded = JWT.decode(token, nil, false).first
-
- assert_includes decoded["roles"], "admin"
- end
-
- test "should support custom role claim name" do
- @application.update!(role_claim_name: "user_roles")
- @application.assign_role_to_user!(@user, "admin", source: 'oidc')
-
- token = OidcJwtService.generate_id_token(@user, @application)
- decoded = JWT.decode(token, nil, false).first
-
- assert_includes decoded["user_roles"], "admin"
- assert_nil decoded["roles"]
- end
-end
\ No newline at end of file