diff --git a/app/controllers/admin/applications_controller.rb b/app/controllers/admin/applications_controller.rb index e03fc19..4bc99de 100644 --- a/app/controllers/admin/applications_controller.rb +++ b/app/controllers/admin/applications_controller.rb @@ -1,6 +1,6 @@ module Admin class ApplicationsController < BaseController - before_action :set_application, only: [:show, :edit, :update, :destroy, :regenerate_credentials] + 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 diff --git a/app/controllers/oidc_controller.rb b/app/controllers/oidc_controller.rb index 1382c31..850a4c2 100644 --- a/app/controllers/oidc_controller.rb +++ b/app/controllers/oidc_controller.rb @@ -161,7 +161,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 diff --git a/app/javascript/controllers/role_management_controller.js b/app/javascript/controllers/role_management_controller.js new file mode 100644 index 0000000..95b2cd0 --- /dev/null +++ b/app/javascript/controllers/role_management_controller.js @@ -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") + } + } +} \ No newline at end of file diff --git a/app/models/application.rb b/app/models/application.rb index ba14f3a..0d59a93 100644 --- a/app/models/application.rb +++ b/app/models/application.rb @@ -1,8 +1,12 @@ 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 :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 +14,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 +24,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 +36,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 +76,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 diff --git a/app/models/application_role.rb b/app/models/application_role.rb new file mode 100644 index 0000000..91b5317 --- /dev/null +++ b/app/models/application_role.rb @@ -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 \ No newline at end of file diff --git a/app/models/user.rb b/app/models/user.rb index 7d0484e..0ceff25 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -3,6 +3,8 @@ 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 # Token generation for passwordless flows generates_token_for :invitation, expires_in: 7.days diff --git a/app/models/user_role_assignment.rb b/app/models/user_role_assignment.rb new file mode 100644 index 0000000..84dba51 --- /dev/null +++ b/app/models/user_role_assignment.rb @@ -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 \ No newline at end of file diff --git a/app/services/oidc_jwt_service.rb b/app/services/oidc_jwt_service.rb index 94c0cc3..fabbcd6 100644 --- a/app/services/oidc_jwt_service.rb +++ b/app/services/oidc_jwt_service.rb @@ -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 diff --git a/app/services/role_mapping_engine.rb b/app/services/role_mapping_engine.rb new file mode 100644 index 0000000..f0ce8d9 --- /dev/null +++ b/app/services/role_mapping_engine.rb @@ -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 \ No newline at end of file diff --git a/app/views/admin/applications/_form.html.erb b/app/views/admin/applications/_form.html.erb index a3be5af..97f0945 100644 --- a/app/views/admin/applications/_form.html.erb +++ b/app/views/admin/applications/_form.html.erb @@ -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" %>
One URI per line. These are the allowed callback URLs for your application.
+ + +Controls how external roles are mapped and synchronized.
+Manage OIDC applications.
+Manage OIDC Clients.
Mode: <%= @application.role_mapping_mode.humanize %>
+ <% if @application.role_claim_name.present? %> +Role Claim: <%= @application.role_claim_name %>
+ <% end %> + <% if @application.role_prefix.present? %> +Role Prefix: <%= @application.role_prefix %>
+ <% end %> +Role mapping is currently disabled for this application. Enable it in the application settings to manage roles.
+<%= role.description %>
+ <% end %> + + +Assigned Users:
+Mode: <%= @application.role_mapping_mode.humanize %>
+ <% if @application.role_claim_name.present? %> +Role Claim: <%= @application.role_claim_name %>
+ <% end %> + <% if @application.role_prefix.present? %> +Role Prefix: <%= @application.role_prefix %>
+ <% end %> +Role mapping is currently disabled for this application. Enable it in the application settings to manage roles.
+<%= role.description %>
+ <% end %> + + +Assigned Users:
+Mode: <%= @application.role_mapping_mode.humanize %>
+ <% if @application.role_claim_name.present? %> +Role Claim: <%= @application.role_claim_name %>
+ <% end %> + <% if @application.role_prefix.present? %> +Role Prefix: <%= @application.role_prefix %>
+ <% end %> +Role mapping is currently disabled for this application. Enable it in the application settings to manage roles.
+<%= role.description %>
+ <% end %> + + +Assigned Users:
+Mode: <%= @application.role_mapping_mode.humanize %>
+ <% if @application.role_claim_name.present? %> +Role Claim: <%= @application.role_claim_name %>
+ <% end %> + <% if @application.role_prefix.present? %> +Role Prefix: <%= @application.role_prefix %>
+ <% end %> +Role mapping is currently disabled for this application. Enable it in the application settings to manage roles.
+<%= role.description %>
+ <% end %> + + +Assigned Users:
+Copy these credentials now. The client secret will not be shown again.
+<%= flash[:client_id] %>
+ <%= flash[:client_secret] %>
+ <%= @application.client_secret %>
+ + To get a new client secret, use the "Regenerate Credentials" button above. +