14 Commits

Author SHA1 Message Date
Dan Milne
39757a43dc Add an invite system
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-10-24 23:26:07 +11:00
Dan Milne
5463723455 Increase the thing
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-10-24 20:48:58 +11:00
Dan Milne
e36850f8ba Bug fix
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-10-24 17:07:12 +11:00
Dan Milne
0af3dbefed Remember that we concented.
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-10-24 17:01:03 +11:00
Dan Milne
d6c24e50df Whoops - add oidc logout
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-10-24 16:47:55 +11:00
Dan Milne
8c80343b89 Add nonce to the auth codes
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-10-24 16:34:38 +11:00
Dan Milne
2db7f6a9df Don't use turbo when we expect to redirect
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-10-24 16:27:05 +11:00
Dan Milne
e3f202f574 Fix and cleanup
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-10-24 16:17:56 +11:00
Dan Milne
c7f391541a Fix - remove debug
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-10-24 16:08:01 +11:00
Dan Milne
8e56210b74 More debugging
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-10-24 16:01:18 +11:00
Dan Milne
056c69e002 More debugging
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-10-24 15:54:08 +11:00
Dan Milne
225b6b0bb6 Debuging
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-10-24 15:47:29 +11:00
Dan Milne
fbda018065 Bug fix approving an Application
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-10-24 15:41:31 +11:00
Dan Milne
12e0ef66ed OIDC app creation with encrypted secrets and application roles
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-10-24 14:47:24 +11:00
45 changed files with 2367 additions and 95 deletions

View File

@@ -1,6 +1,6 @@
module Admin
class ApplicationsController < BaseController
before_action :set_application, only: [:show, :edit, :update, :destroy, :regenerate_credentials]
before_action :set_application, only: [:show, :edit, :update, :destroy, :regenerate_credentials, :roles, :create_role, :update_role, :assign_role, :remove_role]
def index
@applications = Application.order(created_at: :desc)
@@ -17,6 +17,7 @@ module Admin
def create
@application = Application.new(application_params)
@available_groups = Group.order(:name)
if @application.save
# Handle group assignments
@@ -25,9 +26,22 @@ module Admin
@application.allowed_groups = Group.where(id: group_ids)
end
redirect_to admin_application_path(@application), notice: "Application created successfully."
# Get the plain text client secret to show one time
client_secret = nil
if @application.oidc?
client_secret = @application.generate_new_client_secret!
end
if @application.oidc? && client_secret
flash[:notice] = "Application created successfully."
flash[:client_id] = @application.client_id
flash[:client_secret] = client_secret
else
flash[:notice] = "Application created successfully."
end
redirect_to admin_application_path(@application)
else
@available_groups = Group.order(:name)
render :new, status: :unprocessable_entity
end
end
@@ -60,16 +74,69 @@ module Admin
def regenerate_credentials
if @application.oidc?
@application.update!(
client_id: SecureRandom.urlsafe_base64(32),
client_secret: SecureRandom.urlsafe_base64(48)
)
redirect_to admin_application_path(@application), notice: "Credentials regenerated successfully. Make sure to update your application configuration."
# Generate new client ID and secret
new_client_id = SecureRandom.urlsafe_base64(32)
client_secret = @application.generate_new_client_secret!
@application.update!(client_id: new_client_id)
flash[:notice] = "Credentials regenerated successfully."
flash[:client_id] = @application.client_id
flash[:client_secret] = client_secret
redirect_to admin_application_path(@application)
else
redirect_to admin_application_path(@application), alert: "Only OIDC applications have credentials."
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
@@ -77,7 +144,14 @@ module Admin
end
def application_params
params.require(:application).permit(:name, :slug, :app_type, :active, :redirect_uris, :description, :metadata)
params.require(:application).permit(
:name, :slug, :app_type, :active, :redirect_uris, :description, :metadata,
:role_mapping_mode, :role_prefix, :role_claim_name, managed_permissions: {}
)
end
def role_params
params.require(:application_role).permit(:name, :display_name, :description, :active, permissions: {})
end
end
end

View File

@@ -1,6 +1,6 @@
module Admin
class UsersController < BaseController
before_action :set_user, only: [:show, :edit, :update, :destroy]
before_action :set_user, only: [:show, :edit, :update, :destroy, :resend_invitation]
def index
@users = User.order(created_at: :desc)
@@ -16,9 +16,11 @@ module Admin
def create
@user = User.new(user_params)
@user.password = SecureRandom.alphanumeric(16) if user_params[:password].blank?
@user.status = :pending_invitation
if @user.save
redirect_to admin_users_path, notice: "User created successfully."
InvitationsMailer.invite_user(@user).deliver_later
redirect_to admin_users_path, notice: "User created successfully. Invitation email sent to #{@user.email_address}."
else
render :new, status: :unprocessable_entity
end
@@ -46,6 +48,16 @@ module Admin
end
end
def resend_invitation
unless @user.pending_invitation?
redirect_to admin_users_path, alert: "Cannot send invitation. User is not pending invitation."
return
end
InvitationsMailer.invite_user(@user).deliver_later
redirect_to admin_users_path, notice: "Invitation email resent to #{@user.email_address}."
end
def destroy
# Prevent admin from deleting themselves
if @user == Current.session.user

View File

@@ -0,0 +1,31 @@
class InvitationsController < ApplicationController
allow_unauthenticated_access
before_action :set_user_by_invitation_token, only: %i[ show update ]
def show
# Show the password setup form
end
def update
if @user.update(params.permit(:password, :password_confirmation))
@user.update!(status: :active)
@user.sessions.destroy_all
redirect_to new_session_path, notice: "Your account has been set up successfully. Please sign in."
else
redirect_to invite_path(params[:token]), alert: "Passwords did not match."
end
end
private
def set_user_by_invitation_token
@user = User.find_by_invitation_login_token!(params[:token])
# Check if user is still pending invitation
unless @user.pending_invitation?
redirect_to new_session_path, alert: "This invitation has already been used or is no longer valid."
end
rescue ActiveSupport::MessageVerifier::InvalidSignature
redirect_to new_session_path, alert: "Invitation link is invalid or has expired."
end
end

View File

@@ -1,7 +1,7 @@
class OidcController < ApplicationController
# Discovery and JWKS endpoints are public
allow_unauthenticated_access only: [:discovery, :jwks, :token, :userinfo]
skip_before_action :verify_authenticity_token, only: [:token]
allow_unauthenticated_access only: [:discovery, :jwks, :token, :userinfo, :logout]
skip_before_action :verify_authenticity_token, only: [:token, :logout]
# GET /.well-known/openid-configuration
def discovery
@@ -13,6 +13,7 @@ class OidcController < ApplicationController
token_endpoint: "#{base_url}/oauth/token",
userinfo_endpoint: "#{base_url}/oauth/userinfo",
jwks_uri: "#{base_url}/.well-known/jwks.json",
end_session_endpoint: "#{base_url}/logout",
response_types_supported: ["code"],
subject_types_supported: ["public"],
id_token_signing_alg_values_supported: ["RS256"],
@@ -81,6 +82,30 @@ class OidcController < ApplicationController
return
end
requested_scopes = scope.split(" ")
# Check if user has already granted consent for these scopes
existing_consent = user.has_oidc_consent?(@application, requested_scopes)
if existing_consent
# User has already consented, generate authorization code directly
code = SecureRandom.urlsafe_base64(32)
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: user,
code: code,
redirect_uri: redirect_uri,
scope: scope,
nonce: nonce,
expires_at: 10.minutes.from_now
)
# Redirect back to client with authorization code
redirect_uri = "#{redirect_uri}?code=#{code}"
redirect_uri += "&state=#{state}" if state.present?
redirect_to redirect_uri, allow_other_host: true
return
end
# Store OAuth parameters for consent page
session[:oauth_params] = {
client_id: client_id,
@@ -92,7 +117,7 @@ class OidcController < ApplicationController
# Render consent page
@redirect_uri = redirect_uri
@scopes = scope.split(" ")
@scopes = requested_scopes
render :consent
end
@@ -108,36 +133,47 @@ class OidcController < ApplicationController
# User denied consent
if params[:deny].present?
session.delete(:oauth_params)
error_uri = "#{oauth_params[:redirect_uri]}?error=access_denied"
error_uri += "&state=#{oauth_params[:state]}" if oauth_params[:state]
error_uri = "#{oauth_params['redirect_uri']}?error=access_denied"
error_uri += "&state=#{oauth_params['state']}" if oauth_params['state']
redirect_to error_uri, allow_other_host: true
return
end
# Find the application
application = Application.find_by(client_id: oauth_params[:client_id])
client_id = oauth_params['client_id']
application = Application.find_by(client_id: client_id, app_type: "oidc")
user = Current.session.user
# Record user consent
requested_scopes = oauth_params['scope'].split(' ')
OidcUserConsent.upsert(
{
user_id: user.id,
application_id: application.id,
scopes_granted: requested_scopes.join(' '),
granted_at: Time.current
},
unique_by: [:user_id, :application_id]
)
# Generate authorization code
code = SecureRandom.urlsafe_base64(32)
auth_code = OidcAuthorizationCode.create!(
application: application,
user: user,
code: code,
redirect_uri: oauth_params[:redirect_uri],
scope: oauth_params[:scope],
redirect_uri: oauth_params['redirect_uri'],
scope: oauth_params['scope'],
nonce: oauth_params['nonce'],
expires_at: 10.minutes.from_now
)
# Store nonce in the authorization code metadata if needed
# For now, we'll pass it through the code itself
# Clear OAuth params from session
session.delete(:oauth_params)
# Redirect back to client with authorization code
redirect_uri = "#{oauth_params[:redirect_uri]}?code=#{code}"
redirect_uri += "&state=#{oauth_params[:state]}" if oauth_params[:state]
redirect_uri = "#{oauth_params['redirect_uri']}?code=#{code}"
redirect_uri += "&state=#{oauth_params['state']}" if oauth_params['state']
redirect_to redirect_uri, allow_other_host: true
end
@@ -161,7 +197,7 @@ class OidcController < ApplicationController
# Find and validate the application
application = Application.find_by(client_id: client_id)
unless application && application.client_secret == client_secret
unless application && application.authenticate_client_secret(client_secret)
render json: { error: "invalid_client" }, status: :unauthorized
return
end
@@ -210,7 +246,7 @@ class OidcController < ApplicationController
)
# Generate ID token
id_token = OidcJwtService.generate_id_token(user, application)
id_token = OidcJwtService.generate_id_token(user, application, nonce: auth_code.nonce)
# Return tokens
render json: {
@@ -269,6 +305,33 @@ class OidcController < ApplicationController
render json: claims
end
# GET /logout
def logout
# OpenID Connect RP-Initiated Logout
# Handle id_token_hint and post_logout_redirect_uri parameters
id_token_hint = params[:id_token_hint]
post_logout_redirect_uri = params[:post_logout_redirect_uri]
state = params[:state]
# If user is authenticated, log them out
if authenticated?
# Invalidate the current session
Current.session&.destroy
reset_session
end
# If post_logout_redirect_uri is provided, redirect there
if post_logout_redirect_uri.present?
redirect_uri = post_logout_redirect_uri
redirect_uri += "?state=#{state}" if state.present?
redirect_to redirect_uri, allow_other_host: true
else
# Default redirect to home page
redirect_to root_path
end
end
private
def extract_client_credentials

View File

@@ -2,6 +2,7 @@ class ProfilesController < ApplicationController
def show
@user = Current.session.user
@active_sessions = @user.sessions.active.order(last_activity_at: :desc)
@connected_applications = @user.oidc_user_consents.includes(:application).order(granted_at: :desc)
end
def update
@@ -33,6 +34,34 @@ class ProfilesController < ApplicationController
end
end
def revoke_consent
@user = Current.session.user
application = Application.find(params[:application_id])
# Check if user has consent for this application
consent = @user.oidc_user_consents.find_by(application: application)
unless consent
redirect_to profile_path, alert: "No consent found for this application."
return
end
# Revoke the consent
consent.destroy
redirect_to profile_path, notice: "Successfully revoked access to #{application.name}."
end
def revoke_all_consents
@user = Current.session.user
count = @user.oidc_user_consents.count
if count > 0
@user.oidc_user_consents.destroy_all
redirect_to profile_path, notice: "Successfully revoked access to #{count} applications."
else
redirect_to profile_path, alert: "No applications to revoke."
end
end
private
def email_params

View File

@@ -1,7 +1,7 @@
class SessionsController < ApplicationController
allow_unauthenticated_access only: %i[ new create verify_totp ]
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to signin_path, alert: "Too many attempts. Try again later." }
rate_limit to: 5, within: 3.minutes, only: :verify_totp, with: -> { redirect_to totp_verification_path, alert: "Too many attempts. Try again later." }
rate_limit to: 20, within: 3.minutes, only: :create, with: -> { redirect_to signin_path, alert: "Too many attempts. Try again later." }
rate_limit to: 10, within: 3.minutes, only: :verify_totp, with: -> { redirect_to totp_verification_path, alert: "Too many attempts. Try again later." }
def new
# Redirect to signup if this is first run
@@ -23,7 +23,11 @@ class SessionsController < ApplicationController
# Check if user is active
unless user.active?
redirect_to signin_path, alert: "Your account is not active. Please contact an administrator."
if user.pending_invitation?
redirect_to signin_path, alert: "Please check your email for an invitation to set up your account."
else
redirect_to signin_path, alert: "Your account is not active. Please contact an administrator."
end
return
end

View File

@@ -0,0 +1,51 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["userSelect", "assignLink", "editForm"]
connect() {
console.log("Role management controller connected")
}
assignRole(event) {
event.preventDefault()
const link = event.currentTarget
const roleId = link.dataset.roleId
const select = document.getElementById(`assign-user-${roleId}`)
if (!select.value) {
alert("Please select a user")
return
}
// Update the href with the selected user ID
const originalHref = link.href
const newHref = originalHref.replace("PLACEHOLDER", select.value)
// Navigate to the updated URL
window.location.href = newHref
}
toggleEdit(event) {
event.preventDefault()
const roleId = event.currentTarget.dataset.roleId
const editForm = document.getElementById(`edit-role-${roleId}`)
if (editForm) {
editForm.classList.toggle("hidden")
}
}
hideEdit(event) {
event.preventDefault()
const roleId = event.currentTarget.dataset.roleId
const editForm = document.getElementById(`edit-role-${roleId}`)
if (editForm) {
editForm.classList.add("hidden")
}
}
}

View File

@@ -0,0 +1,6 @@
class InvitationsMailer < ApplicationMailer
def invite_user(user)
@user = user
mail subject: "You're invited to join Clinch", to: user.email_address
end
end

View File

@@ -1,8 +1,13 @@
class Application < ApplicationRecord
has_secure_password :client_secret
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 },
@@ -10,6 +15,7 @@ class Application < ApplicationRecord
validates :app_type, presence: true,
inclusion: { in: %w[oidc saml] }
validates :client_id, uniqueness: { allow_nil: true }
validates :role_mapping_mode, inclusion: { in: %w[disabled oidc_managed hybrid] }, allow_blank: true
normalizes :slug, with: ->(slug) { slug.strip.downcase }
@@ -19,6 +25,8 @@ class Application < ApplicationRecord
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") }
# Type checks
def oidc?
@@ -29,6 +37,19 @@ class Application < ApplicationRecord
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'
end
# Access control
def user_allowed?(user)
return false unless active?
@@ -56,10 +77,67 @@ class Application < ApplicationRecord
{}
end
def parsed_managed_permissions
return {} unless managed_permissions.present?
managed_permissions.is_a?(Hash) ? managed_permissions : JSON.parse(managed_permissions)
rescue JSON::ParserError
{}
end
# Role management methods
def user_roles(user)
application_roles.joins(:user_role_assignments)
.where(user_role_assignments: { user: user })
.active
end
def user_has_role?(user, role_name)
user_roles(user).exists?(name: role_name)
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)
end
def remove_role_from_user!(user, role_name)
role = application_roles.find_by!(name: role_name)
role.remove_from_user!(user)
end
# Enhanced access control with roles
def user_allowed_with_roles?(user)
return user_allowed?(user) unless role_mapping_enabled?
# For OIDC managed roles, check if user has any roles assigned
if oidc_managed_roles?
return user_roles(user).exists?
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
user_allowed?(user)
end
# Generate and return a new client secret
def generate_new_client_secret!
secret = SecureRandom.urlsafe_base64(48)
self.client_secret = secret
self.save!
secret
end
private
def generate_client_credentials
self.client_id ||= SecureRandom.urlsafe_base64(32)
self.client_secret ||= SecureRandom.urlsafe_base64(48)
# Generate and hash the client secret
if new_record? && client_secret.blank?
secret = SecureRandom.urlsafe_base64(48)
self.client_secret = secret
end
end
end

View File

@@ -0,0 +1,26 @@
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

@@ -0,0 +1,52 @@
class OidcUserConsent < ApplicationRecord
belongs_to :user
belongs_to :application
validates :user, :application, :scopes_granted, :granted_at, presence: true
validates :user_id, uniqueness: { scope: :application_id }
before_validation :set_granted_at, on: :create
# Parse scopes_granted into an array
def scopes
scopes_granted.split(' ')
end
# Set scopes from an array
def scopes=(scope_array)
self.scopes_granted = Array(scope_array).uniq.join(' ')
end
# Check if this consent covers the requested scopes
def covers_scopes?(requested_scopes)
requested = Array(requested_scopes).map(&:to_s)
granted = scopes
# All requested scopes must be included in granted scopes
(requested - granted).empty?
end
# Get a human-readable list of scopes
def formatted_scopes
scopes.map do |scope|
case scope
when 'openid'
'Basic authentication'
when 'profile'
'Profile information'
when 'email'
'Email address'
when 'groups'
'Group membership'
else
scope.humanize
end
end.join(', ')
end
private
def set_granted_at
self.granted_at ||= Time.current
end
end

View File

@@ -3,6 +3,9 @@ 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
generates_token_for :invitation, expires_in: 7.days
@@ -71,6 +74,21 @@ class User < ApplicationRecord
JSON.parse(backup_codes)
end
def has_oidc_consent?(application, requested_scopes)
oidc_user_consents
.where(application: application)
.find { |consent| consent.covers_scopes?(requested_scopes) }
end
def revoke_consent!(application)
consent = oidc_user_consents.find_by(application: application)
consent&.destroy
end
def revoke_all_consents!
oidc_user_consents.destroy_all
end
private
def generate_backup_codes

View File

@@ -0,0 +1,15 @@
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,6 +27,11 @@ 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)
end
JWT.encode(payload, private_key, "RS256", { kid: key_id, typ: "JWT" })
end
@@ -88,5 +93,50 @@ 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

@@ -0,0 +1,127 @@
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

@@ -51,6 +51,52 @@
<%= 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>
<!-- 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>
<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 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>
</div>
<div>
@@ -86,14 +132,30 @@
// Show/hide OIDC 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');
function updateFieldVisibility() {
const isOidc = appTypeSelect.value === 'oidc';
const roleMappingEnabled = roleMappingMode && ['oidc_managed', 'hybrid'].includes(roleMappingMode.value);
if (oidcFields) {
oidcFields.style.display = isOidc ? 'block' : 'none';
}
if (roleMappingAdvanced) {
roleMappingAdvanced.style.display = isOidc && roleMappingEnabled ? 'block' : 'none';
}
}
if (appTypeSelect && oidcFields) {
appTypeSelect.addEventListener('change', function() {
if (this.value === 'oidc') {
oidcFields.style.display = 'block';
} else {
oidcFields.style.display = 'none';
}
});
appTypeSelect.addEventListener('change', updateFieldVisibility);
}
if (roleMappingMode) {
roleMappingMode.addEventListener('change', updateFieldVisibility);
}
// Initialize visibility on page load
updateFieldVisibility();
</script>

View File

@@ -1,7 +1,7 @@
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-2xl font-semibold text-gray-900">Applications</h1>
<p class="mt-2 text-sm text-gray-700">Manage OIDC applications.</p>
<p class="mt-2 text-sm text-gray-700">Manage OIDC Clients.</p>
</div>
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<%= link_to "New Application", new_admin_application_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" %>

View File

@@ -0,0 +1,125 @@
<% 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

@@ -0,0 +1,173 @@
<% 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

@@ -0,0 +1,179 @@
<% 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

@@ -0,0 +1,173 @@
<% 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

@@ -1,4 +1,21 @@
<div class="mb-6">
<% if flash[:client_id] && flash[:client_secret] %>
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-4 mb-6">
<h4 class="text-sm font-medium text-yellow-800 mb-2">🔐 OIDC Client Credentials</h4>
<p class="text-xs text-yellow-700 mb-3">Copy these credentials now. The client secret will not be shown again.</p>
<div class="space-y-2">
<div>
<span class="text-xs font-medium text-yellow-700">Client ID:</span>
</div>
<code class="block bg-yellow-100 px-3 py-2 rounded font-mono text-xs break-all"><%= flash[:client_id] %></code>
<div class="mt-3">
<span class="text-xs font-medium text-yellow-700">Client Secret:</span>
</div>
<code class="block bg-yellow-100 px-3 py-2 rounded font-mono text-xs break-all"><%= flash[:client_secret] %></code>
</div>
</div>
<% end %>
<div class="sm:flex sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-semibold text-gray-900"><%= @application.name %></h1>
@@ -6,6 +23,9 @@
</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>
@@ -64,7 +84,12 @@
<div>
<dt class="text-sm font-medium text-gray-500">Client Secret</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"><%= @application.client_secret %></code>
<div class="bg-gray-100 px-3 py-2 rounded text-xs text-gray-500 italic">
🔒 Client secret is stored securely and cannot be displayed
</div>
<p class="mt-2 text-xs text-gray-500">
To get a new client secret, use the "Regenerate Credentials" button above.
</p>
</dd>
</div>
<div>

View File

@@ -0,0 +1,22 @@
<div class="mx-auto md:w-2/3 w-full">
<% if alert = flash[:alert] %>
<p class="py-2 px-3 bg-red-50 mb-5 text-red-500 font-medium rounded-lg inline-block" id="alert"><%= alert %></p>
<% end %>
<h1 class="font-bold text-4xl">Welcome to Clinch!</h1>
<p class="mt-2 text-gray-600">You've been invited to join Clinch. Please create your password to complete your account setup.</p>
<%= form_with url: invite_path(params[:token]), method: :put, class: "contents" do |form| %>
<div class="my-5">
<%= form.password_field :password, required: true, autocomplete: "new-password", placeholder: "Enter your password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
</div>
<div class="my-5">
<%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Confirm your password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
</div>
<div class="inline">
<%= form.submit "Create Account", class: "w-full sm:w-auto text-center rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer" %>
</div>
<% end %>
</div>

View File

@@ -57,7 +57,7 @@
</div>
</div>
<%= form_with url: oauth_consent_path, method: :post, class: "space-y-3" do |form| %>
<%= form_with url: oauth_consent_path, method: :post, class: "space-y-3", data: { turbo: false } do |form| %>
<%= form.submit "Authorize",
class: "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>

View File

@@ -1,7 +1,7 @@
<div class="space-y-8">
<div>
<h1 class="text-3xl font-bold text-gray-900">Profile & Settings</h1>
<p class="mt-2 text-sm text-gray-600">Manage your account settings and security preferences.</p>
<h1 class="text-3xl font-bold text-gray-900">Account Security</h1>
<p class="mt-2 text-sm text-gray-600">Manage your account settings, active sessions, and connected applications.</p>
</div>
<!-- Account Information -->
@@ -199,6 +199,44 @@
}
</script>
<!-- Connected Applications -->
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg font-medium leading-6 text-gray-900">Connected Applications</h3>
<div class="mt-2 max-w-xl text-sm text-gray-500">
<p>These applications have access to your account. You can revoke access at any time.</p>
</div>
<div class="mt-5">
<% if @connected_applications.any? %>
<ul role="list" class="divide-y divide-gray-200">
<% @connected_applications.each do |consent| %>
<li class="py-4">
<div class="flex items-center justify-between">
<div class="flex flex-col">
<p class="text-sm font-medium text-gray-900">
<%= consent.application.name %>
</p>
<p class="mt-1 text-sm text-gray-500">
Access to: <%= consent.formatted_scopes %>
</p>
<p class="mt-1 text-xs text-gray-400">
Authorized <%= time_ago_in_words(consent.granted_at) %> ago
</p>
</div>
<%= button_to "Revoke Access", revoke_consent_profile_path(application_id: consent.application.id), method: :delete,
class: "inline-flex items-center rounded-md border border-red-300 bg-white px-3 py-2 text-sm font-medium text-red-700 shadow-sm hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2",
form: { data: { turbo_confirm: "Are you sure you want to revoke access to #{consent.application.name}? You'll need to re-authorize this application to use it again." } } %>
</div>
</li>
<% end %>
</ul>
<% else %>
<p class="text-sm text-gray-500">No connected applications.</p>
<% end %>
</div>
</div>
</div>
<!-- Active Sessions -->
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
@@ -243,4 +281,27 @@
</div>
</div>
</div>
<!-- Global Security Actions -->
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg font-medium leading-6 text-gray-900">Security Actions</h3>
<div class="mt-2 max-w-xl text-sm text-gray-500">
<p>Use these actions to quickly secure your account. Be careful - these actions cannot be undone.</p>
</div>
<div class="mt-5 flex flex-wrap gap-4">
<% if @active_sessions.count > 1 %>
<%= button_to "Sign Out Everywhere Else", session_path(Current.session), method: :delete,
class: "inline-flex items-center rounded-md border border-orange-300 bg-white px-4 py-2 text-sm font-medium text-orange-700 shadow-sm hover:bg-orange-50 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2",
form: { data: { turbo_confirm: "This will sign you out from all other devices except this one. Are you sure?" } } %>
<% end %>
<% if @connected_applications.any? %>
<%= button_to "Revoke All App Access", revoke_all_consents_profile_path, method: :delete,
class: "inline-flex items-center rounded-md border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-700 shadow-sm hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2",
form: { data: { turbo_confirm: "This will revoke access from all connected applications. You'll need to re-authorize each application to use them again. Are you sure?" } } %>
<% end %>
</div>
</div>
</div>
</div>

View File

@@ -25,6 +25,7 @@ Rails.application.routes.draw do
post "/oauth/authorize/consent", to: "oidc#consent", as: :oauth_consent
post "/oauth/token", to: "oidc#token"
get "/oauth/userinfo", to: "oidc#userinfo"
get "/logout", to: "oidc#logout"
# ForwardAuth / Trusted Header SSO
namespace :api do
@@ -33,7 +34,12 @@ Rails.application.routes.draw do
# Authenticated routes
root "dashboard#index"
resource :profile, only: [:show, :update]
resource :profile, only: [:show, :update] do
member do
delete :revoke_consent
delete :revoke_all_consents
end
end
resources :sessions, only: [] do
member do
delete :destroy, action: :destroy_other
@@ -54,6 +60,11 @@ 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

View File

@@ -0,0 +1,32 @@
class AddRoleMappingToApplications < ActiveRecord::Migration[8.1]
def change
add_column :applications, :role_mapping_mode, :string, default: 'disabled', null: false
add_column :applications, :role_prefix, :string
add_column :applications, :managed_permissions, :json, default: {}
add_column :applications, :role_claim_name, :string, default: 'roles'
create_table :application_roles do |t|
t.references :application, null: false, foreign_key: true
t.string :name, null: false
t.string :display_name
t.text :description
t.json :permissions, default: {}
t.boolean :active, default: true
t.timestamps
end
add_index :application_roles, [:application_id, :name], unique: true
create_table :user_role_assignments do |t|
t.references :user, null: false, foreign_key: true
t.references :application_role, null: false, foreign_key: true
t.string :source, default: 'oidc' # 'oidc', 'manual', 'group_sync'
t.json :metadata, default: {}
t.timestamps
end
add_index :user_role_assignments, [:user_id, :application_role_id], unique: true
end
end

View File

@@ -0,0 +1,5 @@
class AddDescriptionToApplications < ActiveRecord::Migration[8.1]
def change
add_column :applications, :description, :text
end
end

View File

@@ -0,0 +1,6 @@
class AddClientSecretHashToApplications < ActiveRecord::Migration[8.1]
def change
add_column :applications, :client_secret_hash, :string
remove_column :applications, :client_secret, :string
end
end

View File

@@ -0,0 +1,5 @@
class RenameClientSecretHashToClientSecretDigest < ActiveRecord::Migration[8.1]
def change
rename_column :applications, :client_secret_hash, :client_secret_digest
end
end

View File

@@ -0,0 +1,5 @@
class AddNonceToOidcAuthorizationCodes < ActiveRecord::Migration[8.1]
def change
add_column :oidc_authorization_codes, :nonce, :string
end
end

View File

@@ -0,0 +1,17 @@
class CreateOidcUserConsents < ActiveRecord::Migration[8.1]
def change
create_table :oidc_user_consents do |t|
t.references :user, null: false, foreign_key: true
t.references :application, null: false, foreign_key: true
t.text :scopes_granted, null: false
t.datetime :granted_at, null: false
t.timestamps
end
# Add unique index to prevent duplicate consent records
add_index :oidc_user_consents, [:user_id, :application_id], unique: true
# Add index for querying recent consents
add_index :oidc_user_consents, :granted_at
end
end

53
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_23_234744) do
ActiveRecord::Schema[8.1].define(version: 2025_10_24_055739) do
create_table "application_groups", force: :cascade do |t|
t.integer "application_id", null: false
t.datetime "created_at", null: false
@@ -21,15 +21,33 @@ ActiveRecord::Schema[8.1].define(version: 2025_10_23_234744) 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
t.string "client_id"
t.string "client_secret"
t.string "client_secret_digest"
t.datetime "created_at", null: false
t.text "description"
t.json "managed_permissions", default: {}
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"
@@ -82,6 +100,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_10_23_234744) do
t.string "code", null: false
t.datetime "created_at", null: false
t.datetime "expires_at", null: false
t.string "nonce"
t.string "redirect_uri", null: false
t.string "scope"
t.datetime "updated_at", null: false
@@ -94,6 +113,19 @@ ActiveRecord::Schema[8.1].define(version: 2025_10_23_234744) do
t.index ["user_id"], name: "index_oidc_authorization_codes_on_user_id"
end
create_table "oidc_user_consents", force: :cascade do |t|
t.integer "application_id", null: false
t.datetime "created_at", null: false
t.datetime "granted_at", null: false
t.text "scopes_granted", null: false
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.index ["application_id"], name: "index_oidc_user_consents_on_application_id"
t.index ["granted_at"], name: "index_oidc_user_consents_on_granted_at"
t.index ["user_id", "application_id"], name: "index_oidc_user_consents_on_user_id_and_application_id", unique: true
t.index ["user_id"], name: "index_oidc_user_consents_on_user_id"
end
create_table "sessions", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "device_name"
@@ -119,6 +151,18 @@ ActiveRecord::Schema[8.1].define(version: 2025_10_23_234744) 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"
@@ -135,13 +179,18 @@ ActiveRecord::Schema[8.1].define(version: 2025_10_23_234744) 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"
add_foreign_key "oidc_authorization_codes", "users"
add_foreign_key "oidc_user_consents", "applications"
add_foreign_key "oidc_user_consents", "users"
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,21 +1,26 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
name: MyString
slug: MyString
app_type: MyString
client_id: MyString
client_secret: MyString
redirect_uris: MyText
metadata: MyText
active: false
<% require 'bcrypt' %>
two:
name: MyString
slug: MyString
app_type: MyString
client_id: MyString
client_secret: MyString
redirect_uris: MyText
metadata: MyText
active: false
kavita_app:
name: Kavita Reader
slug: kavita-reader
app_type: oidc
client_id: <%= SecureRandom.urlsafe_base64(32) %>
client_secret_digest: <%= BCrypt::Password.create(SecureRandom.urlsafe_base64(48)) %>
redirect_uris: |
https://kavita.example.com/signin-oidc
https://kavita.example.com/signout-callback-oidc
metadata: "{}"
active: true
another_app:
name: Another App
slug: another-app
app_type: oidc
client_id: <%= SecureRandom.urlsafe_base64(32) %>
client_secret_digest: <%= BCrypt::Password.create(SecureRandom.urlsafe_base64(48)) %>
redirect_uris: |
https://app.example.com/auth/callback
metadata: "{}"
active: true

View File

@@ -1,9 +1,9 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
name: MyString
description: MyText
admin_group:
name: Administrators
description: System administrators with full access
two:
name: MyString
description: MyText
editor_group:
name: Editors
description: Content editors with limited access

View File

@@ -1,15 +1,15 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
token: MyString
application: one
user: one
scope: MyString
expires_at: 2025-10-23 16:40:39
token: <%= SecureRandom.urlsafe_base64(32) %>
application: kavita_app
user: alice
scope: "openid profile email"
expires_at: 2025-12-31 23:59:59
two:
token: MyString
application: two
user: two
scope: MyString
expires_at: 2025-10-23 16:40:39
token: <%= SecureRandom.urlsafe_base64(32) %>
application: another_app
user: bob
scope: "openid profile email"
expires_at: 2025-12-31 23:59:59

View File

@@ -1,19 +1,19 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
code: MyString
application: one
user: one
redirect_uri: MyString
scope: MyString
expires_at: 2025-10-23 16:40:38
code: <%= SecureRandom.urlsafe_base64(32) %>
application: kavita_app
user: alice
redirect_uri: "https://kavita.example.com/signin-oidc"
scope: "openid profile email"
expires_at: 2025-12-31 23:59:59
used: false
two:
code: MyString
application: two
user: two
redirect_uri: MyString
scope: MyString
expires_at: 2025-10-23 16:40:38
code: <%= SecureRandom.urlsafe_base64(32) %>
application: another_app
user: bob
redirect_uri: "https://app.example.com/auth/callback"
scope: "openid profile email"
expires_at: 2025-12-31 23:59:59
used: false

13
test/fixtures/oidc_user_consents.yml vendored Normal file
View File

@@ -0,0 +1,13 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
user: one
application: one
scopes_granted: MyText
granted_at: 2025-10-24 16:57:39
two:
user: two
application: two
scopes_granted: MyText
granted_at: 2025-10-24 16:57:39

View File

@@ -1,9 +1,13 @@
<% password_digest = BCrypt::Password.create("password") %>
one:
email_address: one@example.com
alice:
email_address: alice@example.com
password_digest: <%= password_digest %>
admin: true
status: 0 # active
two:
email_address: two@example.com
bob:
email_address: bob@example.com
password_digest: <%= password_digest %>
admin: false
status: 0 # active

View File

@@ -0,0 +1,210 @@
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

@@ -0,0 +1,86 @@
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

@@ -0,0 +1,7 @@
require "test_helper"
class OidcUserConsentTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

View File

@@ -0,0 +1,87 @@
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

@@ -0,0 +1,163 @@
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

@@ -0,0 +1,111 @@
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