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
This commit is contained in:
11
README.md
11
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
31
app/controllers/api/csp_controller.rb
Normal file
31
app/controllers/api/csp_controller.rb
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
<div>
|
||||
<%= 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? %>
|
||||
<p class="mt-1 text-sm text-gray-500">Application type cannot be changed after creation.</p>
|
||||
<% 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" %>
|
||||
<p class="mt-1 text-sm text-gray-500">One URI per line. These are the allowed callback URLs for your application.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Role Mapping Configuration -->
|
||||
<div class="border-t border-gray-200 pt-6">
|
||||
<h4 class="text-base font-semibold text-gray-900 mb-4">Role Mapping Configuration</h4>
|
||||
<!-- Forward Auth-specific fields -->
|
||||
<div id="forward-auth-fields" class="space-y-6 border-t border-gray-200 pt-6" style="<%= 'display: none;' unless application.forward_auth? %>">
|
||||
<h3 class="text-base font-semibold text-gray-900">Forward Auth Configuration</h3>
|
||||
|
||||
<div>
|
||||
<%= 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" } %>
|
||||
<p class="mt-1 text-sm text-gray-500">Controls how external roles are mapped and synchronized.</p>
|
||||
</div>
|
||||
<div>
|
||||
<%= 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" %>
|
||||
<p class="mt-1 text-sm text-gray-500">Domain pattern to match. Use * for wildcard subdomains (e.g., *.example.com matches app.example.com, api.example.com, etc.)</p>
|
||||
</div>
|
||||
|
||||
<div id="role-mapping-advanced" class="mt-4 space-y-4 border-t border-gray-200 pt-4" style="<%= 'display: none;' unless application.role_mapping_enabled? %>">
|
||||
<div>
|
||||
<%= 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" %>
|
||||
<p class="mt-1 text-sm text-gray-500">Name of the claim that contains role information (default: 'roles').</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= 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-" %>
|
||||
<p class="mt-1 text-sm text-gray-500">Only roles starting with this prefix will be mapped. Useful for multi-tenant scenarios.</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<label class="block text-sm font-medium text-gray-700">Managed Permissions</label>
|
||||
|
||||
<div class="flex items-center">
|
||||
<%= 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" %>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<%= 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" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<%= 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"}' %>
|
||||
<p class="mt-1 text-sm text-gray-500">Optional: Override default headers. Leave empty to use defaults: X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Groups, X-Remote-Admin</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -129,33 +100,29 @@
|
||||
<% end %>
|
||||
|
||||
<script>
|
||||
// Show/hide OIDC fields based on app type selection
|
||||
// Show/hide type-specific fields based on app type selection
|
||||
const appTypeSelect = document.querySelector('#application_app_type');
|
||||
const oidcFields = document.querySelector('#oidc-fields');
|
||||
const roleMappingMode = document.querySelector('#application_role_mapping_mode');
|
||||
const roleMappingAdvanced = document.querySelector('#role-mapping-advanced');
|
||||
const forwardAuthFields = document.querySelector('#forward-auth-fields');
|
||||
|
||||
function updateFieldVisibility() {
|
||||
const isOidc = appTypeSelect.value === 'oidc';
|
||||
const roleMappingEnabled = roleMappingMode && ['oidc_managed', 'hybrid'].includes(roleMappingMode.value);
|
||||
if (!appTypeSelect) return;
|
||||
|
||||
const appType = appTypeSelect.value;
|
||||
|
||||
if (oidcFields) {
|
||||
oidcFields.style.display = isOidc ? 'block' : 'none';
|
||||
oidcFields.style.display = appType === 'oidc' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
if (roleMappingAdvanced) {
|
||||
roleMappingAdvanced.style.display = isOidc && roleMappingEnabled ? 'block' : 'none';
|
||||
if (forwardAuthFields) {
|
||||
forwardAuthFields.style.display = appType === 'forward_auth' ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
if (appTypeSelect && oidcFields) {
|
||||
if (appTypeSelect) {
|
||||
appTypeSelect.addEventListener('change', updateFieldVisibility);
|
||||
}
|
||||
|
||||
if (roleMappingMode) {
|
||||
roleMappingMode.addEventListener('change', updateFieldVisibility);
|
||||
}
|
||||
|
||||
// Initialize visibility on page load
|
||||
updateFieldVisibility();
|
||||
</script>
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
<% content_for :title, "Role Management - #{@application.name}" %>
|
||||
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900">
|
||||
Role Management for <%= @application.name %>
|
||||
</h3>
|
||||
<%= link_to "← Back to Application", admin_application_path(@application), class: "text-sm text-blue-600 hover:text-blue-500" %>
|
||||
</div>
|
||||
|
||||
<% if @application.role_mapping_enabled? %>
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-md p-4 mb-6">
|
||||
<div class="flex">
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-blue-800">Role Mapping Configuration</h3>
|
||||
<div class="mt-2 text-sm text-blue-700">
|
||||
<p>Mode: <strong><%= @application.role_mapping_mode.humanize %></strong></p>
|
||||
<% if @application.role_claim_name.present? %>
|
||||
<p>Role Claim: <strong><%= @application.role_claim_name %></strong></p>
|
||||
<% end %>
|
||||
<% if @application.role_prefix.present? %>
|
||||
<p>Role Prefix: <strong><%= @application.role_prefix %></strong></p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-4 mb-6">
|
||||
<div class="flex">
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800">Role Mapping Disabled</h3>
|
||||
<div class="mt-2 text-sm text-yellow-700">
|
||||
<p>Role mapping is currently disabled for this application. Enable it in the application settings to manage roles.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Create New Role -->
|
||||
<div class="border-b border-gray-200 pb-6 mb-6">
|
||||
<h4 class="text-md font-medium text-gray-900 mb-4">Create New Role</h4>
|
||||
<%= form_with(model: [:admin, @application, ApplicationRole.new], url: create_role_admin_application_path(@application), local: true, class: "space-y-4") do |form| %>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<%= 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" %>
|
||||
</div>
|
||||
<div>
|
||||
<%= 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" %>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<%= 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" %>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<%= 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" %>
|
||||
</div>
|
||||
<div>
|
||||
<%= 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" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Existing Roles -->
|
||||
<div class="space-y-6">
|
||||
<h4 class="text-md font-medium text-gray-900">Existing Roles</h4>
|
||||
|
||||
<% if @application_roles.any? %>
|
||||
<div class="space-y-4">
|
||||
<% @application_roles.each do |role| %>
|
||||
<div class="border border-gray-200 rounded-lg p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-3">
|
||||
<h5 class="text-sm font-medium text-gray-900"><%= role.name %></h5>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
<%= role.display_name %>
|
||||
</span>
|
||||
<% unless role.active %>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
Inactive
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if role.description.present? %>
|
||||
<p class="mt-1 text-sm text-gray-500"><%= role.description %></p>
|
||||
<% end %>
|
||||
|
||||
<!-- Assigned Users -->
|
||||
<div class="mt-3">
|
||||
<p class="text-xs text-gray-500 mb-2">Assigned Users:</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<% role.users.each do |user| %>
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-blue-100 text-blue-800">
|
||||
<%= user.email_address %>
|
||||
<span class="ml-1 text-blue-600">(<%= role.user_role_assignments.find_by(user: user)&.source %>)</span>
|
||||
<%= 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" %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="text-center py-12">
|
||||
<div class="text-gray-500 text-sm">
|
||||
No roles configured yet. Create your first role above to get started with role-based access control.
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,173 +0,0 @@
|
||||
<% content_for :title, "Role Management - #{@application.name}" %>
|
||||
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900">
|
||||
Role Management for <%= @application.name %>
|
||||
</h3>
|
||||
<%= link_to "← Back to Application", admin_application_path(@application), class: "text-sm text-blue-600 hover:text-blue-500" %>
|
||||
</div>
|
||||
|
||||
<% if @application.role_mapping_enabled? %>
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-md p-4 mb-6">
|
||||
<div class="flex">
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-blue-800">Role Mapping Configuration</h3>
|
||||
<div class="mt-2 text-sm text-blue-700">
|
||||
<p>Mode: <strong><%= @application.role_mapping_mode.humanize %></strong></p>
|
||||
<% if @application.role_claim_name.present? %>
|
||||
<p>Role Claim: <strong><%= @application.role_claim_name %></strong></p>
|
||||
<% end %>
|
||||
<% if @application.role_prefix.present? %>
|
||||
<p>Role Prefix: <strong><%= @application.role_prefix %></strong></p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-4 mb-6">
|
||||
<div class="flex">
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800">Role Mapping Disabled</h3>
|
||||
<div class="mt-2 text-sm text-yellow-700">
|
||||
<p>Role mapping is currently disabled for this application. Enable it in the application settings to manage roles.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Create New Role -->
|
||||
<div class="border-b border-gray-200 pb-6 mb-6">
|
||||
<h4 class="text-md font-medium text-gray-900 mb-4">Create New Role</h4>
|
||||
<%= form_with(model: [:admin, @application, ApplicationRole.new], url: create_role_admin_application_path(@application), local: true, class: "space-y-4") do |form| %>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<%= 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" %>
|
||||
</div>
|
||||
<div>
|
||||
<%= 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" %>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<%= 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" %>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<%= 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" %>
|
||||
</div>
|
||||
<div>
|
||||
<%= 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" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Existing Roles -->
|
||||
<div class="space-y-6">
|
||||
<h4 class="text-md font-medium text-gray-900">Existing Roles</h4>
|
||||
|
||||
<% if @application_roles.any? %>
|
||||
<div class="space-y-4">
|
||||
<% @application_roles.each do |role| %>
|
||||
<div class="border border-gray-200 rounded-lg p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-3">
|
||||
<h5 class="text-sm font-medium text-gray-900"><%= role.name %></h5>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
<%= role.display_name %>
|
||||
</span>
|
||||
<% unless role.active %>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
Inactive
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if role.description.present? %>
|
||||
<p class="mt-1 text-sm text-gray-500"><%= role.description %></p>
|
||||
<% end %>
|
||||
|
||||
<!-- Assigned Users -->
|
||||
<div class="mt-3">
|
||||
<p class="text-xs text-gray-500 mb-2">Assigned Users:</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<% role.users.each do |user| %>
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-blue-100 text-blue-800">
|
||||
<%= user.email_address %>
|
||||
<span class="ml-1 text-blue-600">(<%= role.user_role_assignments.find_by(user: user)&.source %>)</span>
|
||||
<%= 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" %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="ml-4 flex-shrink-0">
|
||||
<div class="space-y-2">
|
||||
<!-- Assign Role to User -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<select id="assign-user-<%= role.id %>" class="text-xs rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||
<option value="">Assign to user...</option>
|
||||
<% @available_users.each do |user| %>
|
||||
<% unless role.user_has_role?(user) %>
|
||||
<option value="<%= user.id %>"><%= user.email_address %></option>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</select>
|
||||
<%= 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; }" %>
|
||||
</div>
|
||||
|
||||
<!-- Edit Role -->
|
||||
<%= 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;" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Role Form (Hidden by default) -->
|
||||
<div id="edit-role-<%= role.id %>" class="hidden mt-4 border-t pt-4">
|
||||
<%= form_with(model: [:admin, @application, role], url: update_role_admin_application_path(@application, role_id: role.id), local: true, method: :patch, class: "space-y-3") do |form| %>
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<%= form.label :display_name, "Display Name", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_field :display_name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
</div>
|
||||
<div class="flex items-center pt-6">
|
||||
<%= 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" %>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<%= 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" %>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<%= form.submit "Update Role", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500" %>
|
||||
<%= link_to "Cancel", "#", class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50", onclick: "document.getElementById('edit-role-<%= role.id %>').classList.add('hidden'); return false;" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="text-center py-12">
|
||||
<div class="text-gray-500 text-sm">
|
||||
No roles configured yet. Create your first role above to get started with role-based access control.
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,179 +0,0 @@
|
||||
<% content_for :title, "Role Management - #{@application.name}" %>
|
||||
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900">
|
||||
Role Management for <%= @application.name %>
|
||||
</h3>
|
||||
<%= link_to "← Back to Application", admin_application_path(@application), class: "text-sm text-blue-600 hover:text-blue-500" %>
|
||||
</div>
|
||||
|
||||
<% if @application.role_mapping_enabled? %>
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-md p-4 mb-6">
|
||||
<div class="flex">
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-blue-800">Role Mapping Configuration</h3>
|
||||
<div class="mt-2 text-sm text-blue-700">
|
||||
<p>Mode: <strong><%= @application.role_mapping_mode.humanize %></strong></p>
|
||||
<% if @application.role_claim_name.present? %>
|
||||
<p>Role Claim: <strong><%= @application.role_claim_name %></strong></p>
|
||||
<% end %>
|
||||
<% if @application.role_prefix.present? %>
|
||||
<p>Role Prefix: <strong><%= @application.role_prefix %></strong></p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-4 mb-6">
|
||||
<div class="flex">
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800">Role Mapping Disabled</h3>
|
||||
<div class="mt-2 text-sm text-yellow-700">
|
||||
<p>Role mapping is currently disabled for this application. Enable it in the application settings to manage roles.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Create New Role -->
|
||||
<div class="border-b border-gray-200 pb-6 mb-6">
|
||||
<h4 class="text-md font-medium text-gray-900 mb-4">Create New Role</h4>
|
||||
<%= form_with(model: [:admin, @application, ApplicationRole.new], url: create_role_admin_application_path(@application), local: true, class: "space-y-4") do |form| %>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<%= 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" %>
|
||||
</div>
|
||||
<div>
|
||||
<%= 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" %>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<%= 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" %>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<%= 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" %>
|
||||
</div>
|
||||
<div>
|
||||
<%= 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" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Existing Roles -->
|
||||
<div class="space-y-6" data-controller="role-management">
|
||||
<h4 class="text-md font-medium text-gray-900">Existing Roles</h4>
|
||||
|
||||
<% if @application_roles.any? %>
|
||||
<div class="space-y-4">
|
||||
<% @application_roles.each do |role| %>
|
||||
<div class="border border-gray-200 rounded-lg p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-3">
|
||||
<h5 class="text-sm font-medium text-gray-900"><%= role.name %></h5>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
<%= role.display_name %>
|
||||
</span>
|
||||
<% unless role.active %>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
Inactive
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if role.description.present? %>
|
||||
<p class="mt-1 text-sm text-gray-500"><%= role.description %></p>
|
||||
<% end %>
|
||||
|
||||
<!-- Assigned Users -->
|
||||
<div class="mt-3">
|
||||
<p class="text-xs text-gray-500 mb-2">Assigned Users:</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<% role.users.each do |user| %>
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-blue-100 text-blue-800">
|
||||
<%= user.email_address %>
|
||||
<span class="ml-1 text-blue-600">(<%= role.user_role_assignments.find_by(user: user)&.source %>)</span>
|
||||
<%= 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" %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="ml-4 flex-shrink-0">
|
||||
<div class="space-y-2">
|
||||
<!-- Assign Role to User -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<select id="assign-user-<%= role.id %>" data-role-target="userSelect" data-role-id="<%= role.id %>" class="text-xs rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||
<option value="">Assign to user...</option>
|
||||
<% @available_users.each do |user| %>
|
||||
<% unless role.user_has_role?(user) %>
|
||||
<option value="<%= user.id %>"><%= user.email_address %></option>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</select>
|
||||
<%= 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" } %>
|
||||
</div>
|
||||
|
||||
<!-- Edit Role -->
|
||||
<%= link_to "Edit", "#",
|
||||
class: "text-xs text-gray-600 hover:text-gray-800",
|
||||
data: { action: "click->role-management#toggleEdit" },
|
||||
data: { role_id: role.id } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Role Form (Hidden by default) -->
|
||||
<div id="edit-role-<%= role.id %>" class="hidden mt-4 border-t pt-4" data-role-target="editForm" data-role-id="<%= role.id %>">
|
||||
<%= form_with(model: [:admin, @application, role], url: update_role_admin_application_path(@application, role_id: role.id), local: true, method: :patch, class: "space-y-3") do |form| %>
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<%= form.label :display_name, "Display Name", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_field :display_name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
</div>
|
||||
<div class="flex items-center pt-6">
|
||||
<%= 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" %>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<%= 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" %>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<%= form.submit "Update Role", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500" %>
|
||||
<%= link_to "Cancel", "#",
|
||||
class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50",
|
||||
data: { action: "click->role-management#hideEdit" },
|
||||
data: { role_id: role.id } %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="text-center py-12">
|
||||
<div class="text-gray-500 text-sm">
|
||||
No roles configured yet. Create your first role above to get started with role-based access control.
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,173 +0,0 @@
|
||||
<% content_for :title, "Role Management - #{@application.name}" %>
|
||||
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900">
|
||||
Role Management for <%= @application.name %>
|
||||
</h3>
|
||||
<%= link_to "← Back to Application", admin_application_path(@application), class: "text-sm text-blue-600 hover:text-blue-500" %>
|
||||
</div>
|
||||
|
||||
<% if @application.role_mapping_enabled? %>
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-md p-4 mb-6">
|
||||
<div class="flex">
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-blue-800">Role Mapping Configuration</h3>
|
||||
<div class="mt-2 text-sm text-blue-700">
|
||||
<p>Mode: <strong><%= @application.role_mapping_mode.humanize %></strong></p>
|
||||
<% if @application.role_claim_name.present? %>
|
||||
<p>Role Claim: <strong><%= @application.role_claim_name %></strong></p>
|
||||
<% end %>
|
||||
<% if @application.role_prefix.present? %>
|
||||
<p>Role Prefix: <strong><%= @application.role_prefix %></strong></p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-4 mb-6">
|
||||
<div class="flex">
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800">Role Mapping Disabled</h3>
|
||||
<div class="mt-2 text-sm text-yellow-700">
|
||||
<p>Role mapping is currently disabled for this application. Enable it in the application settings to manage roles.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Create New Role -->
|
||||
<div class="border-b border-gray-200 pb-6 mb-6">
|
||||
<h4 class="text-md font-medium text-gray-900 mb-4">Create New Role</h4>
|
||||
<%= form_with(model: [:admin, @application, ApplicationRole.new], url: create_role_admin_application_path(@application), local: true, class: "space-y-4") do |form| %>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<%= 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" %>
|
||||
</div>
|
||||
<div>
|
||||
<%= 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" %>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<%= 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" %>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<%= 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" %>
|
||||
</div>
|
||||
<div>
|
||||
<%= 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" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Existing Roles -->
|
||||
<div class="space-y-6">
|
||||
<h4 class="text-md font-medium text-gray-900">Existing Roles</h4>
|
||||
|
||||
<% if @application_roles.any? %>
|
||||
<div class="space-y-4">
|
||||
<% @application_roles.each do |role| %>
|
||||
<div class="border border-gray-200 rounded-lg p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-3">
|
||||
<h5 class="text-sm font-medium text-gray-900"><%= role.name %></h5>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
<%= role.display_name %>
|
||||
</span>
|
||||
<% unless role.active %>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
Inactive
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if role.description.present? %>
|
||||
<p class="mt-1 text-sm text-gray-500"><%= role.description %></p>
|
||||
<% end %>
|
||||
|
||||
<!-- Assigned Users -->
|
||||
<div class="mt-3">
|
||||
<p class="text-xs text-gray-500 mb-2">Assigned Users:</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<% role.users.each do |user| %>
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-blue-100 text-blue-800">
|
||||
<%= user.email_address %>
|
||||
<span class="ml-1 text-blue-600">(<%= role.user_role_assignments.find_by(user: user)&.source %>)</span>
|
||||
<%= 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" %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="ml-4 flex-shrink-0">
|
||||
<div class="space-y-2">
|
||||
<!-- Assign Role to User -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<select id="assign-user-<%= role.id %>" class="text-xs rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||
<option value="">Assign to user...</option>
|
||||
<% @available_users.each do |user| %>
|
||||
<% unless role.user_has_role?(user) %>
|
||||
<option value="<%= user.id %>"><%= user.email_address %></option>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</select>
|
||||
<%= 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);" %>
|
||||
</div>
|
||||
|
||||
<!-- Edit Role -->
|
||||
<%= 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;" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Role Form (Hidden by default) -->
|
||||
<div id="edit-role-<%= role.id %>" class="hidden mt-4 border-t pt-4">
|
||||
<%= form_with(model: [:admin, @application, role], url: update_role_admin_application_path(@application, role_id: role.id), local: true, method: :patch, class: "space-y-3") do |form| %>
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<%= form.label :display_name, "Display Name", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_field :display_name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
</div>
|
||||
<div class="flex items-center pt-6">
|
||||
<%= 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" %>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<%= 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" %>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<%= form.submit "Update Role", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500" %>
|
||||
<%= link_to "Cancel", "#", class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50", onclick: "document.getElementById('edit-role-<%= role.id %>').classList.add('hidden'); return false;" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="text-center py-12">
|
||||
<div class="text-gray-500 text-sm">
|
||||
No roles configured yet. Create your first role above to get started with role-based access control.
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -23,9 +23,6 @@
|
||||
</div>
|
||||
<div class="mt-4 sm:mt-0 flex gap-3">
|
||||
<%= 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" %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -47,8 +44,8 @@
|
||||
<% case @application.app_type %>
|
||||
<% when "oidc" %>
|
||||
<span class="inline-flex items-center rounded-full bg-purple-100 px-2 py-1 text-xs font-medium text-purple-700">OIDC</span>
|
||||
<% when "saml" %>
|
||||
<span class="inline-flex items-center rounded-full bg-orange-100 px-2 py-1 text-xs font-medium text-orange-700">SAML</span>
|
||||
<% when "forward_auth" %>
|
||||
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700">Forward Auth</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
@@ -109,6 +106,35 @@
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Forward Auth Configuration (only for Forward Auth apps) -->
|
||||
<% if @application.forward_auth? %>
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="text-base font-semibold leading-6 text-gray-900 mb-4">Forward Auth Configuration</h3>
|
||||
<dl class="space-y-4">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Domain Pattern</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs"><%= @application.domain_pattern %></code>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Headers Configuration</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<% if @application.headers_config.present? && @application.headers_config.any? %>
|
||||
<code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs whitespace-pre-wrap"><%= JSON.pretty_generate(@application.headers_config) %></code>
|
||||
<% else %>
|
||||
<div class="bg-gray-100 px-3 py-2 rounded text-xs text-gray-500">
|
||||
Using default headers: X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Groups, X-Remote-Admin
|
||||
</div>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Group Access Control -->
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
<% content_for :title, "Edit Forward Auth Rule" %>
|
||||
|
||||
<div class="md:flex md:items-center md:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight">
|
||||
Edit Forward Auth Rule
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<%= form_with(model: [:admin, @forward_auth_rule], local: true, class: "space-y-6") do |form| %>
|
||||
<%= render "shared/form_errors", form: form %>
|
||||
|
||||
<div class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2">
|
||||
<div class="px-4 py-6 sm:p-8">
|
||||
<div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
|
||||
<div class="sm:col-span-4">
|
||||
<%= form.label :domain_pattern, class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||
<div class="mt-2">
|
||||
<%= 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" %>
|
||||
</div>
|
||||
<p class="mt-3 text-sm leading-6 text-gray-600">
|
||||
Use patterns like "*.example.com" or "api.example.com". Wildcards (*) are supported.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<%= form.label :active, class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||
<div class="mt-2">
|
||||
<%= 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" } %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-full">
|
||||
<div class="block text-sm font-medium leading-6 text-gray-900 mb-4">
|
||||
Groups
|
||||
</div>
|
||||
<div class="mt-2 space-y-2">
|
||||
<%= 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" } %>
|
||||
</div>
|
||||
<p class="mt-3 text-sm leading-6 text-gray-600">
|
||||
Select groups that are allowed to access this domain. If no groups are selected, all authenticated users will be allowed access (bypass).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="col-span-full">
|
||||
<div class="block text-sm font-medium leading-6 text-gray-900 mb-4">
|
||||
HTTP Headers Configuration
|
||||
</div>
|
||||
<div class="mt-2 space-y-4">
|
||||
<div class="grid grid-cols-1 gap-y-4 sm:grid-cols-2 sm:gap-x-4">
|
||||
<div>
|
||||
<%= label_tag "headers_config[user]", "User Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||
<div class="mt-2">
|
||||
<%= 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" %>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500">Header name for user identity</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= label_tag "headers_config[email]", "Email Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||
<div class="mt-2">
|
||||
<%= 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" %>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500">Header name for user email</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= label_tag "headers_config[name]", "Name Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||
<div class="mt-2">
|
||||
<%= 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" %>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500">Header name for user display name</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= label_tag "headers_config[groups]", "Groups Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||
<div class="mt-2">
|
||||
<%= 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" %>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500">Header name for user groups (comma-separated)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= label_tag "headers_config[admin]", "Admin Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||
<div class="mt-2">
|
||||
<%= 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" %>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500">Header name for admin status (true/false)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 p-4 bg-blue-50 rounded-lg">
|
||||
<h4 class="text-sm font-medium text-blue-900 mb-2">Header Configuration Options:</h4>
|
||||
<ul class="text-sm text-blue-700 space-y-1">
|
||||
<li>• <strong>Default headers:</strong> Use standard headers like Remote-User, Remote-Email</li>
|
||||
<li>• <strong>X- prefixed:</strong> Use X-Remote-User, X-Remote-Email, etc.</li>
|
||||
<li>• <strong>Custom:</strong> Use application-specific headers</li>
|
||||
<li>• <strong>No headers:</strong> Leave fields empty for access-only (like Metube)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center justify-end gap-x-6">
|
||||
<%= 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" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -1,68 +0,0 @@
|
||||
<div class="sm:flex sm:items-center">
|
||||
<div class="sm:flex-auto">
|
||||
<h1 class="text-2xl font-semibold text-gray-900">Forward Auth Rules</h1>
|
||||
<p class="mt-2 text-sm text-gray-700">Manage forward authentication rules for domain-based access control.</p>
|
||||
</div>
|
||||
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
|
||||
<%= 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" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flow-root">
|
||||
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
|
||||
<table class="min-w-full divide-y divide-gray-300">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0">Domain Pattern</th>
|
||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Headers</th>
|
||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Groups</th>
|
||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Status</th>
|
||||
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-0">
|
||||
<span class="sr-only">Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<% @forward_auth_rules.each do |rule| %>
|
||||
<tr>
|
||||
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0">
|
||||
<%= link_to rule.domain_pattern, admin_forward_auth_rule_path(rule), class: "text-blue-600 hover:text-blue-900" %>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
<% if rule.headers_config.blank? %>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">Default</span>
|
||||
<% elsif rule.headers_config.values.all?(&:blank?) %>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">None</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700">Custom</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
<% if rule.allowed_groups.empty? %>
|
||||
<span class="text-gray-400">All users</span>
|
||||
<% else %>
|
||||
<%= rule.allowed_groups.count %> groups
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
<% if rule.active? %>
|
||||
<span class="inline-flex items-center rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-700">Active</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">Inactive</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
||||
<div class="flex justify-end space-x-3">
|
||||
<%= 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" %>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,126 +0,0 @@
|
||||
<% content_for :title, "New Forward Auth Rule" %>
|
||||
|
||||
<div class="md:flex md:items-center md:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight">
|
||||
New Forward Auth Rule
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<%= form_with(model: [:admin, @forward_auth_rule], local: true, class: "space-y-6") do |form| %>
|
||||
<%= render "shared/form_errors", form: form %>
|
||||
|
||||
<div class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2">
|
||||
<div class="px-4 py-6 sm:p-8">
|
||||
<div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
|
||||
<div class="sm:col-span-4">
|
||||
<%= form.label :domain_pattern, class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||
<div class="mt-2">
|
||||
<%= 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" %>
|
||||
</div>
|
||||
<p class="mt-3 text-sm leading-6 text-gray-600">
|
||||
Use patterns like "*.example.com" or "api.example.com". Wildcards (*) are supported.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<%= form.label :active, class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||
<div class="mt-2">
|
||||
<%= 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" } %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-full">
|
||||
<div class="block text-sm font-medium leading-6 text-gray-900 mb-4">
|
||||
Groups
|
||||
</div>
|
||||
<div class="mt-2 space-y-2">
|
||||
<%= 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" } %>
|
||||
</div>
|
||||
<p class="mt-3 text-sm leading-6 text-gray-600">
|
||||
Select groups that are allowed to access this domain. If no groups are selected, all authenticated users will be allowed access (bypass).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="col-span-full">
|
||||
<div class="block text-sm font-medium leading-6 text-gray-900 mb-4">
|
||||
HTTP Headers Configuration
|
||||
</div>
|
||||
<div class="mt-2 space-y-4">
|
||||
<div class="grid grid-cols-1 gap-y-4 sm:grid-cols-2 sm:gap-x-4">
|
||||
<div>
|
||||
<%= label_tag "headers_config[user]", "User Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||
<div class="mt-2">
|
||||
<%= 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" %>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500">Header name for user identity</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= label_tag "headers_config[email]", "Email Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||
<div class="mt-2">
|
||||
<%= 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" %>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500">Header name for user email</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= label_tag "headers_config[name]", "Name Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||
<div class="mt-2">
|
||||
<%= 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" %>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500">Header name for user display name</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= label_tag "headers_config[groups]", "Groups Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||
<div class="mt-2">
|
||||
<%= 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" %>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500">Header name for user groups (comma-separated)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= label_tag "headers_config[admin]", "Admin Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||
<div class="mt-2">
|
||||
<%= 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" %>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500">Header name for admin status (true/false)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 p-4 bg-blue-50 rounded-lg">
|
||||
<h4 class="text-sm font-medium text-blue-900 mb-2">Header Configuration Options:</h4>
|
||||
<ul class="text-sm text-blue-700 space-y-1">
|
||||
<li>• <strong>Default headers:</strong> Use standard headers like Remote-User, Remote-Email</li>
|
||||
<li>• <strong>X- prefixed:</strong> Use X-Remote-User, X-Remote-Email, etc.</li>
|
||||
<li>• <strong>Custom:</strong> Use application-specific headers</li>
|
||||
<li>• <strong>No headers:</strong> Leave fields empty for access-only (like Metube)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center justify-end gap-x-6">
|
||||
<%= 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" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -1,116 +0,0 @@
|
||||
<div class="mb-6">
|
||||
<div class="sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900"><%= @forward_auth_rule.domain_pattern %></h1>
|
||||
<p class="mt-1 text-sm text-gray-500">Forward authentication rule for domain-based access control</p>
|
||||
</div>
|
||||
<div class="mt-4 sm:mt-0 flex gap-3">
|
||||
<%= 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" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Basic Information -->
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="text-base font-semibold leading-6 text-gray-900 mb-4">Basic Information</h3>
|
||||
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Domain Pattern</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900"><code class="bg-gray-100 px-2 py-1 rounded"><%= @forward_auth_rule.domain_pattern %></code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Status</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<% if @forward_auth_rule.active? %>
|
||||
<span class="inline-flex items-center rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-700">Active</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">Inactive</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Headers Configuration</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<% if @forward_auth_rule.headers_config.blank? %>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">Default</span>
|
||||
<% elsif @forward_auth_rule.headers_config.values.all?(&:blank?) %>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">None</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700">Custom</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header Configuration -->
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="text-base font-semibold leading-6 text-gray-900 mb-4">Header Configuration</h3>
|
||||
<div class="space-y-4">
|
||||
<% effective_headers = @forward_auth_rule.effective_headers %>
|
||||
|
||||
<% if effective_headers.empty? %>
|
||||
<div class="rounded-md bg-gray-50 p-4">
|
||||
<div class="flex">
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-gray-700">
|
||||
No headers configured - access control only.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<dl class="space-y-4">
|
||||
<% effective_headers.each do |key, header_name| %>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500"><%= key.to_s.capitalize %></dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs break-all"><%= header_name %></code>
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
</dl>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group Access Control -->
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="text-base font-semibold leading-6 text-gray-900 mb-4">Access Control</h3>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 mb-2">Allowed Groups</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<% if @allowed_groups.empty? %>
|
||||
<div class="rounded-md bg-blue-50 p-4">
|
||||
<div class="flex">
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-blue-700">
|
||||
No groups assigned - all active users can access this domain.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<ul class="divide-y divide-gray-200 border border-gray-200 rounded-md">
|
||||
<% @allowed_groups.each do |group| %>
|
||||
<li class="px-4 py-3 flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900"><%= group.name %></p>
|
||||
<p class="text-xs text-gray-500"><%= pluralize(group.users.count, "member") %></p>
|
||||
</div>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -49,6 +49,12 @@
|
||||
<p class="mt-1 text-sm text-gray-500">Select which users should be members of this group.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= 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"]}' %>
|
||||
<p class="mt-1 text-sm text-gray-500">Optional: Custom claims to add to OIDC tokens for all members. These will be merged with user-level claims.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<%= 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" %>
|
||||
|
||||
@@ -46,6 +46,12 @@
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= 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"}' %>
|
||||
<p class="mt-1 text-sm text-gray-500">Optional: User-specific custom claims to add to OIDC tokens. These override group-level claims.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<%= 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" %>
|
||||
|
||||
@@ -57,16 +57,6 @@
|
||||
<% end %>
|
||||
</li>
|
||||
|
||||
<!-- Admin: Forward Auth Rules -->
|
||||
<li>
|
||||
<%= 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 %>
|
||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
Forward Auth Rules
|
||||
<% end %>
|
||||
</li>
|
||||
|
||||
<!-- Admin: Groups -->
|
||||
<li>
|
||||
<%= 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 %>
|
||||
</li>
|
||||
<li>
|
||||
<%= 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 %>
|
||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
Forward Auth Rules
|
||||
<% end %>
|
||||
</li>
|
||||
<% end %>
|
||||
<li>
|
||||
<%= 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 %>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
9
db/migrate/20251104015104_remove_forward_auth_tables.rb
Normal file
9
db/migrate/20251104015104_remove_forward_auth_tables.rb
Normal file
@@ -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
|
||||
59
db/schema.rb
generated
59
db/schema.rb
generated
@@ -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
|
||||
|
||||
11
test/fixtures/forward_auth_rules.yml
vendored
11
test/fixtures/forward_auth_rules.yml
vendored
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user