Compare commits

..

2 Commits

Author SHA1 Message Date
Dan Milne
ef15db77f9 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
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-11-04 13:21:55 +11:00
Dan Milne
4d1bc1ab66 Update readme 2025-10-29 22:39:49 +11:00
46 changed files with 343 additions and 2918 deletions

View File

@@ -1,6 +1,7 @@
# Clinch
This software is experiemental. If you'd like to try it out, find bugs, security flaws and improvements, please do.
> [!NOTE]
> This software is experiemental. If you'd like to try it out, find bugs, security flaws and improvements, please do.
**A lightweight, self-hosted identity & SSO portal**
@@ -100,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)
---
@@ -114,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**
@@ -131,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)

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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