From 12e0ef66ed93a2b6dd35c4c4b8614386e01d750e Mon Sep 17 00:00:00 2001 From: Dan Milne Date: Fri, 24 Oct 2025 14:47:24 +1100 Subject: [PATCH] OIDC app creation with encrypted secrets and application roles --- .../admin/applications_controller.rb | 92 +++++++- app/controllers/oidc_controller.rb | 2 +- .../controllers/role_management_controller.js | 51 +++++ app/models/application.rb | 79 ++++++- app/models/application_role.rb | 26 +++ app/models/user.rb | 2 + app/models/user_role_assignment.rb | 15 ++ app/services/oidc_jwt_service.rb | 50 +++++ app/services/role_mapping_engine.rb | 127 +++++++++++ app/views/admin/applications/_form.html.erb | 76 ++++++- app/views/admin/applications/index.html.erb | 2 +- app/views/admin/applications/roles.html.erb | 125 +++++++++++ .../admin/applications/roles_backup.html.erb | 173 +++++++++++++++ .../admin/applications/roles_broken.html.erb | 179 +++++++++++++++ .../admin/applications/roles_complex.html.erb | 173 +++++++++++++++ app/views/admin/applications/show.html.erb | 27 ++- config/routes.rb | 5 + ...012201_add_role_mapping_to_applications.rb | 32 +++ ...4022837_add_description_to_applications.rb | 5 + ..._add_client_secret_hash_to_applications.rb | 6 + ...ent_secret_hash_to_client_secret_digest.rb | 5 + db/schema.rb | 37 ++- test/fixtures/applications.yml | 41 ++-- test/fixtures/groups.yml | 12 +- test/fixtures/oidc_access_tokens.yml | 20 +- test/fixtures/oidc_authorization_codes.yml | 24 +- test/fixtures/users.yml | 12 +- test/integration/oidc_role_mapping_test.rb | 210 ++++++++++++++++++ test/models/application_role_test.rb | 86 +++++++ test/models/user_role_assignment_test.rb | 87 ++++++++ test/services/role_mapping_engine_test.rb | 163 ++++++++++++++ test/unit/role_mapping_test.rb | 111 +++++++++ 32 files changed, 1983 insertions(+), 72 deletions(-) create mode 100644 app/javascript/controllers/role_management_controller.js create mode 100644 app/models/application_role.rb create mode 100644 app/models/user_role_assignment.rb create mode 100644 app/services/role_mapping_engine.rb create mode 100644 app/views/admin/applications/roles.html.erb create mode 100644 app/views/admin/applications/roles_backup.html.erb create mode 100644 app/views/admin/applications/roles_broken.html.erb create mode 100644 app/views/admin/applications/roles_complex.html.erb create mode 100644 db/migrate/20251024012201_add_role_mapping_to_applications.rb create mode 100644 db/migrate/20251024022837_add_description_to_applications.rb create mode 100644 db/migrate/20251024032255_add_client_secret_hash_to_applications.rb create mode 100644 db/migrate/20251024033007_rename_client_secret_hash_to_client_secret_digest.rb create mode 100644 test/integration/oidc_role_mapping_test.rb create mode 100644 test/models/application_role_test.rb create mode 100644 test/models/user_role_assignment_test.rb create mode 100644 test/services/role_mapping_engine_test.rb create mode 100644 test/unit/role_mapping_test.rb 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.

+ + +
+

Role Mapping Configuration

+ +
+ <%= form.label :role_mapping_mode, "Role Mapping Mode", class: "block text-sm font-medium text-gray-700" %> + <%= form.select :role_mapping_mode, + options_for_select([ + ["Disabled", "disabled"], + ["OIDC Managed", "oidc_managed"], + ["Hybrid (Groups + Roles)", "hybrid"] + ], application.role_mapping_mode || "disabled"), + {}, + { class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" } %> +

Controls how external roles are mapped and synchronized.

+
+ +
+
+ <%= form.label :role_claim_name, "Role Claim Name", class: "block text-sm font-medium text-gray-700" %> + <%= form.text_field :role_claim_name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "roles" %> +

Name of the claim that contains role information (default: 'roles').

+
+ +
+ <%= form.label :role_prefix, "Role Prefix (Optional)", class: "block text-sm font-medium text-gray-700" %> + <%= form.text_field :role_prefix, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "app-" %> +

Only roles starting with this prefix will be mapped. Useful for multi-tenant scenarios.

+
+ +
+ + +
+ <%= form.check_box :managed_permissions, { multiple: true, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" }, "include_permissions", "" %> + <%= form.label :managed_permissions_include_permissions, "Include role permissions in tokens", class: "ml-2 block text-sm text-gray-900" %> +
+ +
+ <%= form.check_box :managed_permissions, { multiple: true, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" }, "include_metadata", "" %> + <%= form.label :managed_permissions_include_metadata, "Include role metadata in tokens", class: "ml-2 block text-sm text-gray-900" %> +
+
+
+
@@ -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(); diff --git a/app/views/admin/applications/index.html.erb b/app/views/admin/applications/index.html.erb index 9919584..30f27fe 100644 --- a/app/views/admin/applications/index.html.erb +++ b/app/views/admin/applications/index.html.erb @@ -1,7 +1,7 @@

Applications

-

Manage OIDC applications.

+

Manage OIDC Clients.

<%= 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" %> diff --git a/app/views/admin/applications/roles.html.erb b/app/views/admin/applications/roles.html.erb new file mode 100644 index 0000000..bcc53cc --- /dev/null +++ b/app/views/admin/applications/roles.html.erb @@ -0,0 +1,125 @@ +<% content_for :title, "Role Management - #{@application.name}" %> + +
+
+
+

+ Role Management for <%= @application.name %> +

+ <%= link_to "← Back to Application", admin_application_path(@application), class: "text-sm text-blue-600 hover:text-blue-500" %> +
+ + <% if @application.role_mapping_enabled? %> +
+
+
+

Role Mapping Configuration

+
+

Mode: <%= @application.role_mapping_mode.humanize %>

+ <% if @application.role_claim_name.present? %> +

Role Claim: <%= @application.role_claim_name %>

+ <% end %> + <% if @application.role_prefix.present? %> +

Role Prefix: <%= @application.role_prefix %>

+ <% end %> +
+
+
+
+ <% else %> +
+
+
+

Role Mapping Disabled

+
+

Role mapping is currently disabled for this application. Enable it in the application settings to manage roles.

+
+
+
+
+ <% end %> + + +
+

Create New Role

+ <%= form_with(model: [:admin, @application, ApplicationRole.new], url: create_role_admin_application_path(@application), local: true, class: "space-y-4") do |form| %> +
+
+ <%= form.label :name, "Role Name", class: "block text-sm font-medium text-gray-700" %> + <%= form.text_field :name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "admin" %> +
+
+ <%= form.label :display_name, "Display Name", class: "block text-sm font-medium text-gray-700" %> + <%= form.text_field :display_name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Administrator" %> +
+
+
+ <%= form.label :description, class: "block text-sm font-medium text-gray-700" %> + <%= form.text_area :description, rows: 2, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Description of this role's permissions" %> +
+
+ <%= form.check_box :active, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %> + <%= form.label :active, "Active", class: "ml-2 block text-sm text-gray-900" %> +
+
+ <%= form.submit "Create Role", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %> +
+ <% end %> +
+ + +
+

Existing Roles

+ + <% if @application_roles.any? %> +
+ <% @application_roles.each do |role| %> +
+
+
+
+
<%= role.name %>
+ + <%= role.display_name %> + + <% unless role.active %> + + Inactive + + <% end %> +
+ <% if role.description.present? %> +

<%= role.description %>

+ <% end %> + + +
+

Assigned Users:

+
+ <% role.users.each do |user| %> + + <%= user.email_address %> + (<%= role.user_role_assignments.find_by(user: user)&.source %>) + <%= link_to "×", remove_role_admin_application_path(@application, user_id: user.id, role_id: role.id), + method: :post, + data: { confirm: "Remove role from #{user.email_address}?" }, + class: "ml-1 text-blue-600 hover:text-blue-800" %> + + <% end %> +
+
+
+
+
+ <% end %> +
+ <% else %> +
+
+ No roles configured yet. Create your first role above to get started with role-based access control. +
+
+ <% end %> +
+
+
\ No newline at end of file diff --git a/app/views/admin/applications/roles_backup.html.erb b/app/views/admin/applications/roles_backup.html.erb new file mode 100644 index 0000000..594ccfa --- /dev/null +++ b/app/views/admin/applications/roles_backup.html.erb @@ -0,0 +1,173 @@ +<% content_for :title, "Role Management - #{@application.name}" %> + +
+
+
+

+ Role Management for <%= @application.name %> +

+ <%= link_to "← Back to Application", admin_application_path(@application), class: "text-sm text-blue-600 hover:text-blue-500" %> +
+ + <% if @application.role_mapping_enabled? %> +
+
+
+

Role Mapping Configuration

+
+

Mode: <%= @application.role_mapping_mode.humanize %>

+ <% if @application.role_claim_name.present? %> +

Role Claim: <%= @application.role_claim_name %>

+ <% end %> + <% if @application.role_prefix.present? %> +

Role Prefix: <%= @application.role_prefix %>

+ <% end %> +
+
+
+
+ <% else %> +
+
+
+

Role Mapping Disabled

+
+

Role mapping is currently disabled for this application. Enable it in the application settings to manage roles.

+
+
+
+
+ <% end %> + + +
+

Create New Role

+ <%= form_with(model: [:admin, @application, ApplicationRole.new], url: create_role_admin_application_path(@application), local: true, class: "space-y-4") do |form| %> +
+
+ <%= form.label :name, "Role Name", class: "block text-sm font-medium text-gray-700" %> + <%= form.text_field :name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "admin" %> +
+
+ <%= form.label :display_name, "Display Name", class: "block text-sm font-medium text-gray-700" %> + <%= form.text_field :display_name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Administrator" %> +
+
+
+ <%= form.label :description, class: "block text-sm font-medium text-gray-700" %> + <%= form.text_area :description, rows: 2, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Description of this role's permissions" %> +
+
+ <%= form.check_box :active, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %> + <%= form.label :active, "Active", class: "ml-2 block text-sm text-gray-900" %> +
+
+ <%= form.submit "Create Role", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %> +
+ <% end %> +
+ + +
+

Existing Roles

+ + <% if @application_roles.any? %> +
+ <% @application_roles.each do |role| %> +
+
+
+
+
<%= role.name %>
+ + <%= role.display_name %> + + <% unless role.active %> + + Inactive + + <% end %> +
+ <% if role.description.present? %> +

<%= role.description %>

+ <% end %> + + +
+

Assigned Users:

+
+ <% role.users.each do |user| %> + + <%= user.email_address %> + (<%= role.user_role_assignments.find_by(user: user)&.source %>) + <%= link_to "×", remove_role_admin_application_path(@application, user_id: user.id, role_id: role.id), + method: :post, + data: { confirm: "Remove role from #{user.email_address}?" }, + class: "ml-1 text-blue-600 hover:text-blue-800" %> + + <% end %> +
+
+
+ + +
+
+ +
+ + <%= link_to "Assign", assign_role_admin_application_path(@application, role_id: role.id, user_id: "REPLACE_USER_ID"), + method: :post, + class: "text-xs bg-blue-600 px-2 py-1 rounded text-white hover:bg-blue-500", + onclick: "this.href = this.href.replace('REPLACE_USER_ID', document.getElementById('assign-user-<%= role.id %>').value); if (this.href.includes('undefined')) { alert('Please select a user'); return false; }" %> +
+ + + <%= link_to "Edit", "#", class: "text-xs text-gray-600 hover:text-gray-800", onclick: "document.getElementById('edit-role-<%= role.id %>').classList.toggle('hidden'); return false;" %> +
+
+
+ + + +
+ <% end %> +
+ <% else %> +
+
+ No roles configured yet. Create your first role above to get started with role-based access control. +
+
+ <% end %> +
+
+
\ No newline at end of file diff --git a/app/views/admin/applications/roles_broken.html.erb b/app/views/admin/applications/roles_broken.html.erb new file mode 100644 index 0000000..77de5ed --- /dev/null +++ b/app/views/admin/applications/roles_broken.html.erb @@ -0,0 +1,179 @@ +<% content_for :title, "Role Management - #{@application.name}" %> + +
+
+
+

+ Role Management for <%= @application.name %> +

+ <%= link_to "← Back to Application", admin_application_path(@application), class: "text-sm text-blue-600 hover:text-blue-500" %> +
+ + <% if @application.role_mapping_enabled? %> +
+
+
+

Role Mapping Configuration

+
+

Mode: <%= @application.role_mapping_mode.humanize %>

+ <% if @application.role_claim_name.present? %> +

Role Claim: <%= @application.role_claim_name %>

+ <% end %> + <% if @application.role_prefix.present? %> +

Role Prefix: <%= @application.role_prefix %>

+ <% end %> +
+
+
+
+ <% else %> +
+
+
+

Role Mapping Disabled

+
+

Role mapping is currently disabled for this application. Enable it in the application settings to manage roles.

+
+
+
+
+ <% end %> + + +
+

Create New Role

+ <%= form_with(model: [:admin, @application, ApplicationRole.new], url: create_role_admin_application_path(@application), local: true, class: "space-y-4") do |form| %> +
+
+ <%= form.label :name, "Role Name", class: "block text-sm font-medium text-gray-700" %> + <%= form.text_field :name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "admin" %> +
+
+ <%= form.label :display_name, "Display Name", class: "block text-sm font-medium text-gray-700" %> + <%= form.text_field :display_name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Administrator" %> +
+
+
+ <%= form.label :description, class: "block text-sm font-medium text-gray-700" %> + <%= form.text_area :description, rows: 2, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Description of this role's permissions" %> +
+
+ <%= form.check_box :active, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %> + <%= form.label :active, "Active", class: "ml-2 block text-sm text-gray-900" %> +
+
+ <%= form.submit "Create Role", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %> +
+ <% end %> +
+ + +
+

Existing Roles

+ + <% if @application_roles.any? %> +
+ <% @application_roles.each do |role| %> +
+
+
+
+
<%= role.name %>
+ + <%= role.display_name %> + + <% unless role.active %> + + Inactive + + <% end %> +
+ <% if role.description.present? %> +

<%= role.description %>

+ <% end %> + + +
+

Assigned Users:

+
+ <% role.users.each do |user| %> + + <%= user.email_address %> + (<%= role.user_role_assignments.find_by(user: user)&.source %>) + <%= link_to "×", remove_role_admin_application_path(@application, user_id: user.id, role_id: role.id), + method: :post, + data: { confirm: "Remove role from #{user.email_address}?" }, + class: "ml-1 text-blue-600 hover:text-blue-800" %> + + <% end %> +
+
+
+ + +
+
+ +
+ + <%= link_to "Assign", assign_role_admin_application_path(@application, role_id: role.id, user_id: "PLACEHOLDER"), + method: :post, + class: "text-xs bg-blue-600 px-2 py-1 rounded text-white hover:bg-blue-500", + data: { role_target: "assignLink", action: "click->role-management#assignRole" } %> +
+ + + <%= link_to "Edit", "#", + class: "text-xs text-gray-600 hover:text-gray-800", + data: { action: "click->role-management#toggleEdit" }, + data: { role_id: role.id } %> +
+
+
+ + + +
+ <% end %> +
+ <% else %> +
+
+ No roles configured yet. Create your first role above to get started with role-based access control. +
+
+ <% end %> +
+
+
\ No newline at end of file diff --git a/app/views/admin/applications/roles_complex.html.erb b/app/views/admin/applications/roles_complex.html.erb new file mode 100644 index 0000000..b08e81a --- /dev/null +++ b/app/views/admin/applications/roles_complex.html.erb @@ -0,0 +1,173 @@ +<% content_for :title, "Role Management - #{@application.name}" %> + +
+
+
+

+ Role Management for <%= @application.name %> +

+ <%= link_to "← Back to Application", admin_application_path(@application), class: "text-sm text-blue-600 hover:text-blue-500" %> +
+ + <% if @application.role_mapping_enabled? %> +
+
+
+

Role Mapping Configuration

+
+

Mode: <%= @application.role_mapping_mode.humanize %>

+ <% if @application.role_claim_name.present? %> +

Role Claim: <%= @application.role_claim_name %>

+ <% end %> + <% if @application.role_prefix.present? %> +

Role Prefix: <%= @application.role_prefix %>

+ <% end %> +
+
+
+
+ <% else %> +
+
+
+

Role Mapping Disabled

+
+

Role mapping is currently disabled for this application. Enable it in the application settings to manage roles.

+
+
+
+
+ <% end %> + + +
+

Create New Role

+ <%= form_with(model: [:admin, @application, ApplicationRole.new], url: create_role_admin_application_path(@application), local: true, class: "space-y-4") do |form| %> +
+
+ <%= form.label :name, "Role Name", class: "block text-sm font-medium text-gray-700" %> + <%= form.text_field :name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "admin" %> +
+
+ <%= form.label :display_name, "Display Name", class: "block text-sm font-medium text-gray-700" %> + <%= form.text_field :display_name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Administrator" %> +
+
+
+ <%= form.label :description, class: "block text-sm font-medium text-gray-700" %> + <%= form.text_area :description, rows: 2, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Description of this role's permissions" %> +
+
+ <%= form.check_box :active, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %> + <%= form.label :active, "Active", class: "ml-2 block text-sm text-gray-900" %> +
+
+ <%= form.submit "Create Role", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %> +
+ <% end %> +
+ + +
+

Existing Roles

+ + <% if @application_roles.any? %> +
+ <% @application_roles.each do |role| %> +
+
+
+
+
<%= role.name %>
+ + <%= role.display_name %> + + <% unless role.active %> + + Inactive + + <% end %> +
+ <% if role.description.present? %> +

<%= role.description %>

+ <% end %> + + +
+

Assigned Users:

+
+ <% role.users.each do |user| %> + + <%= user.email_address %> + (<%= role.user_role_assignments.find_by(user: user)&.source %>) + <%= link_to "×", remove_role_admin_application_path(@application, user_id: user.id, role_id: role.id), + method: :post, + data: { confirm: "Remove role from #{user.email_address}?" }, + class: "ml-1 text-blue-600 hover:text-blue-800" %> + + <% end %> +
+
+
+ + +
+
+ +
+ + <%= link_to "Assign", assign_role_admin_application_path(@application, role_id: role.id, user_id: "PLACEHOLDER"), + method: :post, + class: "text-xs bg-blue-600 px-2 py-1 rounded text-white hover:bg-blue-500", + onclick: "var select = document.getElementById('assign-user-<%= role.id %>'); var userId = select.value; if (!userId) { alert('Please select a user'); return false; } this.href = this.href.replace('PLACEHOLDER', userId);" %> +
+ + + <%= link_to "Edit", "#", class: "text-xs text-gray-600 hover:text-gray-800", onclick: "document.getElementById('edit-role-<%= role.id %>').classList.toggle('hidden'); return false;" %> +
+
+
+ + + +
+ <% end %> +
+ <% else %> +
+
+ No roles configured yet. Create your first role above to get started with role-based access control. +
+
+ <% end %> +
+
+
\ No newline at end of file diff --git a/app/views/admin/applications/show.html.erb b/app/views/admin/applications/show.html.erb index 092563d..37ffb9a 100644 --- a/app/views/admin/applications/show.html.erb +++ b/app/views/admin/applications/show.html.erb @@ -1,4 +1,21 @@
+ <% if flash[:client_id] && flash[:client_secret] %> +
+

🔐 OIDC Client Credentials

+

Copy these credentials now. The client secret will not be shown again.

+
+
+ Client ID: +
+ <%= flash[:client_id] %> +
+ Client Secret: +
+ <%= flash[:client_secret] %> +
+
+ <% end %> +

<%= @application.name %>

@@ -6,6 +23,9 @@
<%= 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" %>
@@ -64,7 +84,12 @@
Client Secret
- <%= @application.client_secret %> +
+ 🔒 Client secret is stored securely and cannot be displayed +
+

+ To get a new client secret, use the "Regenerate Credentials" button above. +

diff --git a/config/routes.rb b/config/routes.rb index abfbce3..b629fd4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -54,6 +54,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 diff --git a/db/migrate/20251024012201_add_role_mapping_to_applications.rb b/db/migrate/20251024012201_add_role_mapping_to_applications.rb new file mode 100644 index 0000000..ef37fb6 --- /dev/null +++ b/db/migrate/20251024012201_add_role_mapping_to_applications.rb @@ -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 diff --git a/db/migrate/20251024022837_add_description_to_applications.rb b/db/migrate/20251024022837_add_description_to_applications.rb new file mode 100644 index 0000000..b1ee0c0 --- /dev/null +++ b/db/migrate/20251024022837_add_description_to_applications.rb @@ -0,0 +1,5 @@ +class AddDescriptionToApplications < ActiveRecord::Migration[8.1] + def change + add_column :applications, :description, :text + end +end diff --git a/db/migrate/20251024032255_add_client_secret_hash_to_applications.rb b/db/migrate/20251024032255_add_client_secret_hash_to_applications.rb new file mode 100644 index 0000000..358bd8b --- /dev/null +++ b/db/migrate/20251024032255_add_client_secret_hash_to_applications.rb @@ -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 diff --git a/db/migrate/20251024033007_rename_client_secret_hash_to_client_secret_digest.rb b/db/migrate/20251024033007_rename_client_secret_hash_to_client_secret_digest.rb new file mode 100644 index 0000000..9f65b8a --- /dev/null +++ b/db/migrate/20251024033007_rename_client_secret_hash_to_client_secret_digest.rb @@ -0,0 +1,5 @@ +class RenameClientSecretHashToClientSecretDigest < ActiveRecord::Migration[8.1] + def change + rename_column :applications, :client_secret_hash, :client_secret_digest + end +end diff --git a/db/schema.rb b/db/schema.rb index 6f90f28..bb07bc4 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2025_10_23_234744) do +ActiveRecord::Schema[8.1].define(version: 2025_10_24_033007) 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" @@ -119,6 +137,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,6 +165,7 @@ 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" @@ -144,4 +175,6 @@ ActiveRecord::Schema[8.1].define(version: 2025_10_23_234744) do add_foreign_key "sessions", "users" add_foreign_key "user_groups", "groups" add_foreign_key "user_groups", "users" + add_foreign_key "user_role_assignments", "application_roles" + add_foreign_key "user_role_assignments", "users" end diff --git a/test/fixtures/applications.yml b/test/fixtures/applications.yml index b1d9e50..b497f06 100644 --- a/test/fixtures/applications.yml +++ b/test/fixtures/applications.yml @@ -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 diff --git a/test/fixtures/groups.yml b/test/fixtures/groups.yml index 382f6d8..212aa2a 100644 --- a/test/fixtures/groups.yml +++ b/test/fixtures/groups.yml @@ -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 diff --git a/test/fixtures/oidc_access_tokens.yml b/test/fixtures/oidc_access_tokens.yml index 09d5ec7..8f4d05e 100644 --- a/test/fixtures/oidc_access_tokens.yml +++ b/test/fixtures/oidc_access_tokens.yml @@ -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 diff --git a/test/fixtures/oidc_authorization_codes.yml b/test/fixtures/oidc_authorization_codes.yml index 5127f03..3163646 100644 --- a/test/fixtures/oidc_authorization_codes.yml +++ b/test/fixtures/oidc_authorization_codes.yml @@ -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 diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index 0951563..9413a38 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -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 diff --git a/test/integration/oidc_role_mapping_test.rb b/test/integration/oidc_role_mapping_test.rb new file mode 100644 index 0000000..a21de87 --- /dev/null +++ b/test/integration/oidc_role_mapping_test.rb @@ -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 \ No newline at end of file diff --git a/test/models/application_role_test.rb b/test/models/application_role_test.rb new file mode 100644 index 0000000..10c6f74 --- /dev/null +++ b/test/models/application_role_test.rb @@ -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 \ No newline at end of file diff --git a/test/models/user_role_assignment_test.rb b/test/models/user_role_assignment_test.rb new file mode 100644 index 0000000..86a52b3 --- /dev/null +++ b/test/models/user_role_assignment_test.rb @@ -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 \ No newline at end of file diff --git a/test/services/role_mapping_engine_test.rb b/test/services/role_mapping_engine_test.rb new file mode 100644 index 0000000..62ce913 --- /dev/null +++ b/test/services/role_mapping_engine_test.rb @@ -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 \ No newline at end of file diff --git a/test/unit/role_mapping_test.rb b/test/unit/role_mapping_test.rb new file mode 100644 index 0000000..f8fb53f --- /dev/null +++ b/test/unit/role_mapping_test.rb @@ -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 \ No newline at end of file