From ef15db77f9e6fca4ca1a46282bfe498e4faf419f Mon Sep 17 00:00:00 2001 From: Dan Milne Date: Tue, 4 Nov 2025 13:21:55 +1100 Subject: [PATCH] Massive refactor. Merge forward_auth into App, remove references to unimplemented OIDC federation and SAML features. Add group and user custom claims. Groups now allocate which apps a user can use --- README.md | 11 +- .../admin/applications_controller.rb | 55 +-- .../admin/forward_auth_rules_controller.rb | 84 ---- app/controllers/admin/groups_controller.rb | 2 +- app/controllers/admin/users_controller.rb | 2 +- app/controllers/api/csp_controller.rb | 31 ++ .../api/forward_auth_controller.rb | 44 +- app/controllers/oidc_controller.rb | 8 + app/models/application.rb | 120 +++--- app/models/application_role.rb | 26 -- app/models/forward_auth_rule.rb | 94 ----- app/models/forward_auth_rule_group.rb | 6 - app/models/group.rb | 5 + app/models/user.rb | 7 +- app/models/user_role_assignment.rb | 15 - app/services/oidc_jwt_service.rb | 54 +-- app/services/role_mapping_engine.rb | 127 ------ app/views/admin/applications/_form.html.erb | 79 +--- app/views/admin/applications/roles.html.erb | 125 ------ .../admin/applications/roles_backup.html.erb | 173 -------- .../admin/applications/roles_broken.html.erb | 179 -------- .../admin/applications/roles_complex.html.erb | 173 -------- app/views/admin/applications/show.html.erb | 36 +- .../admin/forward_auth_rules/edit.html.erb | 126 ------ .../admin/forward_auth_rules/index.html.erb | 68 --- .../admin/forward_auth_rules/new.html.erb | 126 ------ .../admin/forward_auth_rules/show.html.erb | 116 ----- app/views/admin/groups/_form.html.erb | 6 + app/views/admin/users/_form.html.erb | 6 + app/views/shared/_sidebar.html.erb | 18 - config/routes.rb | 6 - ...4_add_custom_claims_to_groups_and_users.rb | 6 + ...add_forward_auth_fields_to_applications.rb | 10 + ...rate_forward_auth_rules_to_applications.rb | 71 ++++ ..._remove_role_related_tables_and_columns.rb | 15 + ...251104015104_remove_forward_auth_tables.rb | 9 + db/schema.rb | 59 +-- test/fixtures/forward_auth_rules.yml | 11 - test/integration/oidc_role_mapping_test.rb | 210 ---------- test/models/application_role_test.rb | 86 ---- test/models/forward_auth_rule_test.rb | 395 ------------------ test/models/user_role_assignment_test.rb | 87 ---- test/services/oidc_jwt_service_test.rb | 1 - test/services/role_mapping_engine_test.rb | 163 -------- test/simple_role_test.rb | 96 ----- test/unit/role_mapping_test.rb | 111 ----- 46 files changed, 341 insertions(+), 2917 deletions(-) delete mode 100644 app/controllers/admin/forward_auth_rules_controller.rb create mode 100644 app/controllers/api/csp_controller.rb delete mode 100644 app/models/application_role.rb delete mode 100644 app/models/forward_auth_rule.rb delete mode 100644 app/models/forward_auth_rule_group.rb delete mode 100644 app/models/user_role_assignment.rb delete mode 100644 app/services/role_mapping_engine.rb delete mode 100644 app/views/admin/applications/roles.html.erb delete mode 100644 app/views/admin/applications/roles_backup.html.erb delete mode 100644 app/views/admin/applications/roles_broken.html.erb delete mode 100644 app/views/admin/applications/roles_complex.html.erb delete mode 100644 app/views/admin/forward_auth_rules/edit.html.erb delete mode 100644 app/views/admin/forward_auth_rules/index.html.erb delete mode 100644 app/views/admin/forward_auth_rules/new.html.erb delete mode 100644 app/views/admin/forward_auth_rules/show.html.erb create mode 100644 db/migrate/20251104014854_add_custom_claims_to_groups_and_users.rb create mode 100644 db/migrate/20251104014928_add_forward_auth_fields_to_applications.rb create mode 100644 db/migrate/20251104014955_migrate_forward_auth_rules_to_applications.rb create mode 100644 db/migrate/20251104015034_remove_role_related_tables_and_columns.rb create mode 100644 db/migrate/20251104015104_remove_forward_auth_tables.rb delete mode 100644 test/fixtures/forward_auth_rules.yml delete mode 100644 test/integration/oidc_role_mapping_test.rb delete mode 100644 test/models/application_role_test.rb delete mode 100644 test/models/forward_auth_rule_test.rb delete mode 100644 test/models/user_role_assignment_test.rb delete mode 100644 test/services/role_mapping_engine_test.rb delete mode 100644 test/simple_role_test.rb delete mode 100644 test/unit/role_mapping_test.rb 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.

-
- -
- - -
- <%= 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 %> -
-
-
- - -
-
- -
- - <%= 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;" %> -
-
-
- - - -
- <% 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 %> -
-
-
- - -
-
- -
- - <%= 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 } %> -
-
-
- - - -
- <% 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 %> -
-
-
- - -
-
- -
- - <%= 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;" %> -
-
-
- - - -
- <% 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" %> -
-
- -
-
-
- - - - - - - - - - - - <% @forward_auth_rules.each do |rule| %> - - - - - - - - <% end %> - -
Domain PatternHeadersGroupsStatus - Actions -
- <%= 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" %> -
-
-
-
-
\ 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