Default-deny access control with group flags and access enumeration
Some checks failed
Some checks failed
Replaces the implicit "empty allowed_groups means public" rule with explicit default-deny across both OIDC and ForwardAuth. Adds two boolean flags on Group — auto_assign (Keycloak-style auto-join on user create) and admin (members can reach the admin panel) — and drops the users.admin column entirely. Adds "Users with access" and "Accessible applications" panels with via-group badges on the application/user show pages. BEHAVIOR CHANGE: a ForwardAuth app with no allowed_groups previously bypassed authentication entirely; it now returns 403 like any other unauthorized request. The data migration seeds an "everyone" group and attaches it to all previously group-less apps to preserve behavior on existing installs. An "admins" group is seeded and backfilled from any user with the old admin column. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,12 @@ module Admin
|
|||||||
|
|
||||||
def show
|
def show
|
||||||
@allowed_groups = @application.allowed_groups
|
@allowed_groups = @application.allowed_groups
|
||||||
|
@users_with_access = User.where(status: User.statuses[:active])
|
||||||
|
.joins(groups: :applications)
|
||||||
|
.where(applications: {id: @application.id})
|
||||||
|
.distinct
|
||||||
|
.includes(:groups)
|
||||||
|
.order(:email_address)
|
||||||
end
|
end
|
||||||
|
|
||||||
def new
|
def new
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ module Admin
|
|||||||
end
|
end
|
||||||
|
|
||||||
def group_params
|
def group_params
|
||||||
params.require(:group).permit(:name, :description, :custom_claims)
|
params.require(:group).permit(:name, :description, :custom_claims, :auto_assign, :admin)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -7,27 +7,38 @@ module Admin
|
|||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
|
@accessible_applications = Application.where(active: true)
|
||||||
|
.joins(:allowed_groups)
|
||||||
|
.where(groups: {id: @user.groups})
|
||||||
|
.distinct
|
||||||
|
.includes(:allowed_groups)
|
||||||
|
.order(:name)
|
||||||
end
|
end
|
||||||
|
|
||||||
def new
|
def new
|
||||||
@user = User.new
|
@user = User.new
|
||||||
|
@available_groups = Group.order(:name)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@user = User.new(user_params)
|
@user = User.new(user_params)
|
||||||
@user.password = SecureRandom.alphanumeric(16) if user_params[:password].blank?
|
@user.password = SecureRandom.alphanumeric(16) if user_params[:password].blank?
|
||||||
@user.status = :pending_invitation
|
@user.status = :pending_invitation
|
||||||
|
@user.skip_auto_assign = true if params[:auto_assign] == "0"
|
||||||
|
|
||||||
if @user.save
|
if @user.save
|
||||||
|
assign_groups_from_params(@user)
|
||||||
InvitationsMailer.invite_user(@user).deliver_later
|
InvitationsMailer.invite_user(@user).deliver_later
|
||||||
redirect_to admin_users_path, notice: "User created successfully. Invitation email sent to #{@user.email_address}."
|
redirect_to admin_users_path, notice: "User created successfully. Invitation email sent to #{@user.email_address}."
|
||||||
else
|
else
|
||||||
|
@available_groups = Group.order(:name)
|
||||||
render :new, status: :unprocessable_entity
|
render :new, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def edit
|
def edit
|
||||||
@applications = Application.active.order(:name)
|
@applications = Application.active.order(:name)
|
||||||
|
@available_groups = Group.order(:name)
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
@@ -43,6 +54,7 @@ module Admin
|
|||||||
rescue JSON::ParserError
|
rescue JSON::ParserError
|
||||||
@user.errors.add(:custom_claims, "must be valid JSON")
|
@user.errors.add(:custom_claims, "must be valid JSON")
|
||||||
@applications = Application.active.order(:name)
|
@applications = Application.active.order(:name)
|
||||||
|
@available_groups = Group.order(:name)
|
||||||
render :edit, status: :unprocessable_entity
|
render :edit, status: :unprocessable_entity
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -52,9 +64,16 @@ module Admin
|
|||||||
end
|
end
|
||||||
|
|
||||||
if @user.update(update_params)
|
if @user.update(update_params)
|
||||||
|
unless assign_groups_from_params(@user)
|
||||||
|
@applications = Application.active.order(:name)
|
||||||
|
@available_groups = Group.order(:name)
|
||||||
|
render :edit, status: :unprocessable_entity
|
||||||
|
return
|
||||||
|
end
|
||||||
redirect_to admin_users_path, notice: "User updated successfully."
|
redirect_to admin_users_path, notice: "User updated successfully."
|
||||||
else
|
else
|
||||||
@applications = Application.active.order(:name)
|
@applications = Application.active.order(:name)
|
||||||
|
@available_groups = Group.order(:name)
|
||||||
render :edit, status: :unprocessable_entity
|
render :edit, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -122,14 +141,28 @@ module Admin
|
|||||||
end
|
end
|
||||||
|
|
||||||
def user_params
|
def user_params
|
||||||
permitted = [:email_address, :username, :name, :password, :status, :totp_required, :custom_claims]
|
params.require(:user).permit(:email_address, :username, :name, :password, :status, :totp_required, :custom_claims)
|
||||||
|
|
||||||
# Only allow modifying admin status when editing other users (prevent self-demotion)
|
|
||||||
if params[:id] != Current.session.user.id.to_s
|
|
||||||
permitted << :admin
|
|
||||||
end
|
end
|
||||||
|
|
||||||
params.require(:user).permit(*permitted)
|
# Apply group_ids from the form, with a guard preventing self-demotion when
|
||||||
|
# the user is the last member of the last admin group. Returns true on
|
||||||
|
# success, false if a guard fired (caller should re-render).
|
||||||
|
def assign_groups_from_params(user)
|
||||||
|
return true unless params[:user].key?(:group_ids)
|
||||||
|
|
||||||
|
raw_ids = Array(params[:user][:group_ids]).reject(&:blank?).map(&:to_i)
|
||||||
|
new_groups = Group.where(id: raw_ids)
|
||||||
|
|
||||||
|
if user == Current.session.user
|
||||||
|
losing_admin = user.groups.where(admin: true).any? && new_groups.none?(&:admin?)
|
||||||
|
if losing_admin
|
||||||
|
user.errors.add(:base, "you cannot remove yourself from all administrator groups")
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
user.groups = new_groups
|
||||||
|
true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -8,12 +8,16 @@ class UsersController < ApplicationController
|
|||||||
|
|
||||||
def create
|
def create
|
||||||
@user = User.new(user_params)
|
@user = User.new(user_params)
|
||||||
|
|
||||||
# First user becomes admin automatically
|
|
||||||
@user.admin = true if User.count.zero?
|
|
||||||
@user.status = "active"
|
@user.status = "active"
|
||||||
|
first_user = User.count.zero?
|
||||||
|
|
||||||
if @user.save
|
if @user.save
|
||||||
|
# First user automatically becomes a member of every admin group, so they
|
||||||
|
# can reach the admin panel without an existing admin to grant access.
|
||||||
|
if first_user
|
||||||
|
Group.where(admin: true).each { |g| @user.groups << g }
|
||||||
|
end
|
||||||
|
|
||||||
start_new_session_for @user
|
start_new_session_for @user
|
||||||
redirect_to root_path, notice: "Welcome to Clinch! Your account has been created."
|
redirect_to root_path, notice: "Welcome to Clinch! Your account has been created."
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -118,14 +118,12 @@ class Application < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
# Access control
|
# Access control
|
||||||
|
# Default-deny: an empty allowed_groups list means no one gets in.
|
||||||
|
# To make an app accessible to "everyone", attach the seeded auto-assign
|
||||||
|
# group (or any group every user is in).
|
||||||
def user_allowed?(user)
|
def user_allowed?(user)
|
||||||
return false unless active?
|
return false unless active?
|
||||||
return false unless user.active?
|
return false unless user.active?
|
||||||
|
|
||||||
# If no groups are specified, allow all active users
|
|
||||||
return true if allowed_groups.empty?
|
|
||||||
|
|
||||||
# Otherwise, user must be in at least one of the allowed groups
|
|
||||||
(user.groups & allowed_groups).any?
|
(user.groups & allowed_groups).any?
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -168,10 +166,6 @@ class Application < ApplicationRecord
|
|||||||
return "deny" unless active?
|
return "deny" unless active?
|
||||||
return "deny" unless user.active?
|
return "deny" unless user.active?
|
||||||
|
|
||||||
# If no groups specified, bypass authentication
|
|
||||||
return "bypass" if allowed_groups.empty?
|
|
||||||
|
|
||||||
# If user is in allowed groups, determine auth level
|
|
||||||
if user_allowed?(user)
|
if user_allowed?(user)
|
||||||
# Require 2FA if user has TOTP configured, otherwise one factor
|
# Require 2FA if user has TOTP configured, otherwise one factor
|
||||||
user.totp_enabled? ? "two_factor" : "one_factor"
|
user.totp_enabled? ? "two_factor" : "one_factor"
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ class Group < ApplicationRecord
|
|||||||
normalizes :name, with: ->(name) { name.strip.downcase }
|
normalizes :name, with: ->(name) { name.strip.downcase }
|
||||||
validate :no_reserved_claim_names
|
validate :no_reserved_claim_names
|
||||||
|
|
||||||
|
scope :auto_assign, -> { where(auto_assign: true) }
|
||||||
|
scope :admin, -> { where(admin: true) }
|
||||||
|
|
||||||
|
before_destroy :ensure_other_admin_group_exists
|
||||||
|
|
||||||
# Parse custom_claims JSON field
|
# Parse custom_claims JSON field
|
||||||
def parsed_custom_claims
|
def parsed_custom_claims
|
||||||
return {} if custom_claims.blank?
|
return {} if custom_claims.blank?
|
||||||
@@ -23,6 +28,13 @@ class Group < ApplicationRecord
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def ensure_other_admin_group_exists
|
||||||
|
return unless admin?
|
||||||
|
return if Group.where(admin: true).where.not(id: id).exists?
|
||||||
|
errors.add(:base, "cannot delete the last administrators group")
|
||||||
|
throw :abort
|
||||||
|
end
|
||||||
|
|
||||||
def no_reserved_claim_names
|
def no_reserved_claim_names
|
||||||
return if custom_claims.blank?
|
return if custom_claims.blank?
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,18 @@ class User < ApplicationRecord
|
|||||||
enum :status, {active: 0, disabled: 1, pending_invitation: 2}
|
enum :status, {active: 0, disabled: 1, pending_invitation: 2}
|
||||||
|
|
||||||
# Scopes
|
# Scopes
|
||||||
scope :admins, -> { where(admin: true) }
|
scope :admins, -> { joins(:groups).where(groups: {admin: true}).distinct }
|
||||||
|
|
||||||
|
# Set true on a user (or on the user_params) to skip the auto-assign callback
|
||||||
|
# for that record. Used by the admin invite form (opt-out checkbox) and by
|
||||||
|
# tests that want a clean slate.
|
||||||
|
attr_accessor :skip_auto_assign
|
||||||
|
|
||||||
|
after_create :add_to_auto_assign_groups, unless: :skip_auto_assign
|
||||||
|
|
||||||
|
def admin?
|
||||||
|
groups.any?(&:admin?)
|
||||||
|
end
|
||||||
|
|
||||||
# TOTP methods
|
# TOTP methods
|
||||||
def totp_enabled?
|
def totp_enabled?
|
||||||
@@ -222,6 +233,10 @@ class User < ApplicationRecord
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def add_to_auto_assign_groups
|
||||||
|
Group.auto_assign.each { |g| groups << g }
|
||||||
|
end
|
||||||
|
|
||||||
def no_reserved_claim_names
|
def no_reserved_claim_names
|
||||||
return if custom_claims.blank?
|
return if custom_claims.blank?
|
||||||
|
|
||||||
|
|||||||
@@ -274,11 +274,11 @@
|
|||||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Allowed Groups</dt>
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Allowed Groups</dt>
|
||||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||||
<% if @allowed_groups.empty? %>
|
<% if @allowed_groups.empty? %>
|
||||||
<div class="rounded-md bg-blue-50 dark:bg-blue-900/30 p-4">
|
<div class="rounded-md bg-amber-50 dark:bg-amber-900/30 p-4">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div class="ml-3">
|
<div class="ml-3">
|
||||||
<p class="text-sm text-blue-700 dark:text-blue-300">
|
<p class="text-sm text-amber-700 dark:text-amber-300">
|
||||||
No groups assigned - all active users can access this application.
|
No groups assigned — no one can access this application. Attach a group to grant access.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -299,4 +299,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Users with access -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:p-6">
|
||||||
|
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-gray-100 mb-4">
|
||||||
|
Users with access (<%= @users_with_access.count %>)
|
||||||
|
</h3>
|
||||||
|
<% if @users_with_access.any? %>
|
||||||
|
<ul class="divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-md">
|
||||||
|
<% @users_with_access.each do |user| %>
|
||||||
|
<% via = user.groups & @application.allowed_groups %>
|
||||||
|
<li class="px-4 py-3 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-gray-100"><%= user.email_address %></p>
|
||||||
|
<div class="flex flex-wrap gap-1 mt-1">
|
||||||
|
<% via.each do |g| %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs font-medium text-gray-700 dark:text-gray-300">via <%= g.name %></span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<%= link_to "View", admin_user_path(user), class: "text-blue-600 hover:text-blue-900 text-sm" %>
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
<% else %>
|
||||||
|
<div class="rounded-md bg-gray-50 dark:bg-gray-700 p-4">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">No users currently have access. Attach a group to grant access.</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,22 @@
|
|||||||
<%= form.text_area :description, rows: 3, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Optional description of this group" %>
|
<%= form.text_area :description, rows: 3, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Optional description of this group" %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<%= form.check_box :auto_assign, class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
|
||||||
|
<%= form.label :auto_assign, "Auto Assign", class: "ml-2 text-sm text-gray-900 dark:text-gray-100" %>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">New users will be automatically added to this group when invited. You can mark multiple groups as auto-assigned.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<%= form.check_box :admin, class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
|
||||||
|
<%= form.label :admin, "Administrators", class: "ml-2 text-sm text-gray-900 dark:text-gray-100" %>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Members of this group can access the admin panel. Does not grant automatic access to applications.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :user_ids, "Group Members", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
<%= form.label :user_ids, "Group Members", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||||
<div class="mt-2 space-y-2 max-h-64 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-md p-3">
|
<div class="mt-2 space-y-2 max-h-64 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-md p-3">
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<div class="sm:flex sm:items-center sm:justify-between">
|
<div class="sm:flex sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @group.name %></h1>
|
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @group.name %></h1>
|
||||||
|
<% if @group.auto_assign? %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-300">Auto Assign</span>
|
||||||
|
<% end %>
|
||||||
|
<% if @group.admin? %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-0.5 text-xs font-medium text-blue-700 dark:text-blue-300">Administrators</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
<% if @group.description.present? %>
|
<% if @group.description.present? %>
|
||||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400"><%= @group.description %></p>
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400"><%= @group.description %></p>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -33,11 +33,36 @@
|
|||||||
<%= form.select :status, User.statuses.keys.map { |s| [s.titleize, s] }, {}, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
<%= form.select :status, User.statuses.keys.map { |s| [s.titleize, s] }, {}, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= form.label :group_ids, "Group Memberships", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||||
|
<div class="mt-2 space-y-2 max-h-64 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-md p-3">
|
||||||
|
<% if @available_groups.any? %>
|
||||||
|
<% @available_groups.each do |group| %>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<%= form.check_box :admin, class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500", disabled: (user == Current.session.user) %>
|
<%= check_box_tag "user[group_ids][]", group.id, user.groups.include?(group), class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
|
||||||
<%= form.label :admin, "Administrator", class: "ml-2 block text-sm text-gray-900 dark:text-gray-100" %>
|
<%= label_tag "user_group_ids_#{group.id}", group.name, class: "ml-2 text-sm text-gray-900 dark:text-gray-100" %>
|
||||||
<% if user == Current.session.user %>
|
<% if group.admin? %>
|
||||||
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">(Cannot change your own admin status)</span>
|
<span class="ml-2 inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-0.5 text-xs font-medium text-blue-700 dark:text-blue-300">Admin</span>
|
||||||
|
<% end %>
|
||||||
|
<% if group.auto_assign? %>
|
||||||
|
<span class="ml-2 inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-300">Auto Assign</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">No groups available. Create a group first.</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Administrators are members of any group with the Admin flag set. You cannot remove yourself from your last administrator group.</p>
|
||||||
|
<% unless user.persisted? %>
|
||||||
|
<% auto_names = Group.where(auto_assign: true).pluck(:name) %>
|
||||||
|
<% if auto_names.any? %>
|
||||||
|
<div class="mt-2 flex items-center">
|
||||||
|
<%= check_box_tag "auto_assign", "1", true, class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
|
||||||
|
<%= label_tag "auto_assign", "Auto-assign to default groups (#{auto_names.join(", ")})", class: "ml-2 text-sm text-gray-900 dark:text-gray-100" %>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Uncheck to invite this user without auto-assigning the default group(s) — useful for restricted accounts.</p>
|
||||||
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
95
app/views/admin/users/show.html.erb
Normal file
95
app/views/admin/users/show.html.erb
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<div class="mb-6">
|
||||||
|
<div class="sm:flex sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @user.email_address %></h1>
|
||||||
|
<% if @user.admin? %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-0.5 text-xs font-medium text-blue-700 dark:text-blue-300">Admin</span>
|
||||||
|
<% end %>
|
||||||
|
<% case @user.status %>
|
||||||
|
<% when "active" %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-300">Active</span>
|
||||||
|
<% when "disabled" %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs font-medium text-gray-700 dark:text-gray-300">Disabled</span>
|
||||||
|
<% when "pending_invitation" %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-amber-100 dark:bg-amber-900/50 px-2 py-0.5 text-xs font-medium text-amber-700 dark:text-amber-300">Pending Invitation</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% if @user.name.present? %>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400"><%= @user.name %></p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 sm:mt-0 flex gap-3">
|
||||||
|
<%= link_to "Edit", edit_admin_user_path(@user), class: "rounded-md bg-white dark:bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-900 dark:text-gray-200 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Group memberships -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:p-6">
|
||||||
|
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-gray-100 mb-4">
|
||||||
|
Group memberships (<%= @user.groups.count %>)
|
||||||
|
</h3>
|
||||||
|
<% if @user.groups.any? %>
|
||||||
|
<ul class="divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-md">
|
||||||
|
<% @user.groups.order(:name).each do |group| %>
|
||||||
|
<li class="px-4 py-3 flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-gray-100"><%= group.name %></p>
|
||||||
|
<% if group.admin? %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-0.5 text-xs font-medium text-blue-700 dark:text-blue-300">Admin</span>
|
||||||
|
<% end %>
|
||||||
|
<% if group.auto_assign? %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-300">Auto Assign</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<%= link_to "View", admin_group_path(group), class: "text-blue-600 hover:text-blue-900 text-sm" %>
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
<% else %>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">This user is in no groups.</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Accessible applications -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:p-6">
|
||||||
|
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-gray-100 mb-4">
|
||||||
|
Accessible applications (<%= @accessible_applications.count %>)
|
||||||
|
</h3>
|
||||||
|
<% unless @user.active? %>
|
||||||
|
<div class="rounded-md bg-amber-50 dark:bg-amber-900/30 p-4">
|
||||||
|
<p class="text-sm text-amber-700 dark:text-amber-300">
|
||||||
|
User is <%= @user.status.humanize.downcase %> — access is denied regardless of group memberships.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% if @accessible_applications.any? %>
|
||||||
|
<ul class="divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-md">
|
||||||
|
<% @accessible_applications.each do |app| %>
|
||||||
|
<% via = app.allowed_groups & @user.groups %>
|
||||||
|
<li class="px-4 py-3 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-gray-100"><%= app.name %></p>
|
||||||
|
<div class="flex flex-wrap gap-1 mt-1">
|
||||||
|
<% via.each do |g| %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs font-medium text-gray-700 dark:text-gray-300">via <%= g.name %></span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<%= link_to "View", admin_application_path(app), class: "text-blue-600 hover:text-blue-900 text-sm" %>
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
<% else %>
|
||||||
|
<div class="rounded-md bg-gray-50 dark:bg-gray-700 p-4">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">No accessible applications. Add the user to a group that's attached to one or more applications.</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
module Clinch
|
module Clinch
|
||||||
VERSION = "0.12.0"
|
VERSION = "0.13.0"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
class AddAutoAssignAndAdminToGroups < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
add_column :groups, :auto_assign, :boolean, default: false, null: false
|
||||||
|
add_column :groups, :admin, :boolean, default: false, null: false
|
||||||
|
add_index :groups, :auto_assign, where: "auto_assign"
|
||||||
|
add_index :groups, :admin, where: "admin"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
class SeedDefaultGroupsAndMigrateAdmins < ActiveRecord::Migration[8.1]
|
||||||
|
# Data migration: seed "everyone" (auto_assign) and "admins" (admin) groups,
|
||||||
|
# backfill memberships from existing data, attach "everyone" to previously
|
||||||
|
# group-less applications. Idempotent.
|
||||||
|
#
|
||||||
|
# Must run before RemoveAdminFromUsers, because it reads the legacy
|
||||||
|
# users.admin column.
|
||||||
|
|
||||||
|
def up
|
||||||
|
unless Group.exists?(auto_assign: true)
|
||||||
|
everyone = Group.create!(
|
||||||
|
name: "everyone",
|
||||||
|
description: "Auto-assigned to new users. Safe to rename or remove.",
|
||||||
|
auto_assign: true
|
||||||
|
)
|
||||||
|
|
||||||
|
User.where(status: 0).find_each do |u|
|
||||||
|
UserGroup.find_or_create_by!(user_id: u.id, group_id: everyone.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
Application.left_joins(:application_groups)
|
||||||
|
.where(application_groups: {id: nil})
|
||||||
|
.find_each do |app|
|
||||||
|
ApplicationGroup.find_or_create_by!(application_id: app.id, group_id: everyone.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
unless Group.exists?(admin: true)
|
||||||
|
admins = Group.create!(
|
||||||
|
name: "admins",
|
||||||
|
description: "Members can access the admin panel.",
|
||||||
|
admin: true
|
||||||
|
)
|
||||||
|
|
||||||
|
User.where(admin: true).find_each do |u|
|
||||||
|
UserGroup.find_or_create_by!(user_id: u.id, group_id: admins.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
Group.where(name: ["everyone", "admins"]).destroy_all
|
||||||
|
end
|
||||||
|
end
|
||||||
5
db/migrate/20260607000003_remove_admin_from_users.rb
Normal file
5
db/migrate/20260607000003_remove_admin_from_users.rb
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
class RemoveAdminFromUsers < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
remove_column :users, :admin, :boolean, default: false, null: false
|
||||||
|
end
|
||||||
|
end
|
||||||
7
db/schema.rb
generated
7
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.1].define(version: 2026_04_20_080000) do
|
ActiveRecord::Schema[8.1].define(version: 2026_06_07_000003) do
|
||||||
create_table "active_storage_attachments", force: :cascade do |t|
|
create_table "active_storage_attachments", force: :cascade do |t|
|
||||||
t.bigint "blob_id", null: false
|
t.bigint "blob_id", null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
@@ -106,11 +106,15 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_20_080000) do
|
|||||||
end
|
end
|
||||||
|
|
||||||
create_table "groups", force: :cascade do |t|
|
create_table "groups", force: :cascade do |t|
|
||||||
|
t.boolean "admin", default: false, null: false
|
||||||
|
t.boolean "auto_assign", default: false, null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.json "custom_claims", default: {}, null: false
|
t.json "custom_claims", default: {}, null: false
|
||||||
t.text "description"
|
t.text "description"
|
||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["admin"], name: "index_groups_on_admin", where: "admin"
|
||||||
|
t.index ["auto_assign"], name: "index_groups_on_auto_assign", where: "auto_assign"
|
||||||
t.index ["name"], name: "index_groups_on_name", unique: true
|
t.index ["name"], name: "index_groups_on_name", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -225,7 +229,6 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_20_080000) do
|
|||||||
end
|
end
|
||||||
|
|
||||||
create_table "users", force: :cascade do |t|
|
create_table "users", force: :cascade do |t|
|
||||||
t.boolean "admin", default: false, null: false
|
|
||||||
t.json "backup_codes"
|
t.json "backup_codes"
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.json "custom_claims", default: {}, null: false
|
t.json "custom_claims", default: {}, null: false
|
||||||
|
|||||||
@@ -48,5 +48,23 @@ module Admin
|
|||||||
|
|
||||||
assert_equal [app], Group.find_by(name: "new group").applications
|
assert_equal [app], Group.find_by(name: "new group").applications
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "can mark a group as auto_assign and admin" do
|
||||||
|
patch admin_group_path(@group), params: {
|
||||||
|
group: {name: @group.name, auto_assign: "1", admin: "1"}
|
||||||
|
}
|
||||||
|
|
||||||
|
@group.reload
|
||||||
|
assert @group.auto_assign?
|
||||||
|
assert @group.admin?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cannot delete the last admin group" do
|
||||||
|
admins = groups(:admin_group)
|
||||||
|
|
||||||
|
delete admin_group_path(admins)
|
||||||
|
# Destroy was aborted by the before_destroy guard
|
||||||
|
assert Group.exists?(admins.id), "admin group should not have been deleted"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
69
test/controllers/admin/users_controller_test.rb
Normal file
69
test/controllers/admin/users_controller_test.rb
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
module Admin
|
||||||
|
class UsersControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
setup do
|
||||||
|
@admin = users(:two) # in admin_group via fixtures
|
||||||
|
sign_in_as(@admin)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "show loads accessible applications via the user's groups" do
|
||||||
|
kavita = applications(:kavita_app)
|
||||||
|
# alice is in admin_group via fixtures; kavita is attached to admin_group via app_groups
|
||||||
|
get admin_user_path(users(:alice))
|
||||||
|
assert_response :success
|
||||||
|
assert_match kavita.name, response.body
|
||||||
|
# The "via" badge mentions the granting group name
|
||||||
|
assert_match groups(:admin_group).name, response.body
|
||||||
|
end
|
||||||
|
|
||||||
|
test "update assigns group memberships from group_ids" do
|
||||||
|
target = users(:bob)
|
||||||
|
editors = groups(:editor_group)
|
||||||
|
one = groups(:one)
|
||||||
|
|
||||||
|
patch admin_user_path(target), params: {
|
||||||
|
user: {email_address: target.email_address, group_ids: [editors.id, one.id]}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_redirected_to admin_users_path
|
||||||
|
assert_equal [editors, one].sort, target.reload.groups.sort
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cannot remove yourself from the last admin group" do
|
||||||
|
# @admin (users:two) is in admin_group. Removing them via the user form
|
||||||
|
# while no other admin exists is blocked.
|
||||||
|
sole_admin = users(:two)
|
||||||
|
# Strip alice (the other admin) so @admin is the last one.
|
||||||
|
users(:alice).groups.delete(groups(:admin_group))
|
||||||
|
|
||||||
|
patch admin_user_path(sole_admin), params: {
|
||||||
|
user: {email_address: sole_admin.email_address, group_ids: []}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :unprocessable_entity
|
||||||
|
assert sole_admin.reload.admin?, "should still be admin"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "create with auto_assign=0 skips the auto-assign callback" do
|
||||||
|
post admin_users_path, params: {
|
||||||
|
user: {email_address: "restricted@example.com"},
|
||||||
|
auto_assign: "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :redirect
|
||||||
|
created = User.find_by(email_address: "restricted@example.com")
|
||||||
|
assert_not_includes created.groups, groups(:everyone)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "create without auto_assign param auto-joins everyone" do
|
||||||
|
post admin_users_path, params: {
|
||||||
|
user: {email_address: "newbie@example.com"}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :redirect
|
||||||
|
created = User.find_by(email_address: "newbie@example.com")
|
||||||
|
assert_includes created.groups, groups(:everyone)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -11,6 +11,7 @@ module Api
|
|||||||
domain_pattern: "webdav.example.com",
|
domain_pattern: "webdav.example.com",
|
||||||
active: true
|
active: true
|
||||||
)
|
)
|
||||||
|
grant_everyone_access(@app)
|
||||||
@api_key = @user.api_keys.create!(name: "Test Key", application: @app)
|
@api_key = @user.api_keys.create!(name: "Test Key", application: @app)
|
||||||
@token = @api_key.plaintext_token
|
@token = @api_key.plaintext_token
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ module Api
|
|||||||
@admin_user = users(:alice)
|
@admin_user = users(:alice)
|
||||||
@inactive_user = User.create!(email_address: "inactive@example.com", password: "password", status: :disabled)
|
@inactive_user = User.create!(email_address: "inactive@example.com", password: "password", status: :disabled)
|
||||||
@group = groups(:admin_group)
|
@group = groups(:admin_group)
|
||||||
@rule = Application.create!(name: "Test App", slug: "test-app", app_type: "forward_auth", domain_pattern: "test.example.com", active: true)
|
@rule = grant_everyone_access(Application.create!(name: "Test App", slug: "test-app", app_type: "forward_auth", domain_pattern: "test.example.com", active: true))
|
||||||
@inactive_rule = Application.create!(name: "Inactive App", slug: "inactive-app", app_type: "forward_auth", domain_pattern: "inactive.example.com", active: false)
|
@inactive_rule = grant_everyone_access(Application.create!(name: "Inactive App", slug: "inactive-app", app_type: "forward_auth", domain_pattern: "inactive.example.com", active: false))
|
||||||
end
|
end
|
||||||
|
|
||||||
# Authentication Tests
|
# Authentication Tests
|
||||||
@@ -65,7 +65,7 @@ module Api
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "should return 403 when rule exists but user not in allowed groups" do
|
test "should return 403 when rule exists but user not in allowed groups" do
|
||||||
@rule.allowed_groups << @group
|
@rule.allowed_groups = [@group]
|
||||||
sign_in_as(@user) # User not in group
|
sign_in_as(@user) # User not in group
|
||||||
|
|
||||||
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||||
@@ -75,7 +75,7 @@ module Api
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "should return 200 when user is in allowed groups" do
|
test "should return 200 when user is in allowed groups" do
|
||||||
@rule.allowed_groups << @group
|
@rule.allowed_groups = [@group]
|
||||||
@user.groups << @group
|
@user.groups << @group
|
||||||
sign_in_as(@user)
|
sign_in_as(@user)
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ module Api
|
|||||||
|
|
||||||
# Domain Pattern Tests
|
# Domain Pattern Tests
|
||||||
test "should match wildcard domains correctly" do
|
test "should match wildcard domains correctly" do
|
||||||
Application.create!(name: "Wildcard App", slug: "wildcard-app", app_type: "forward_auth", domain_pattern: "*.example.com", active: true)
|
grant_everyone_access Application.create!(name: "Wildcard App", slug: "wildcard-app", app_type: "forward_auth", domain_pattern: "*.example.com", active: true)
|
||||||
sign_in_as(@user)
|
sign_in_as(@user)
|
||||||
|
|
||||||
get "/api/verify", headers: {"X-Forwarded-Host" => "app.example.com"}
|
get "/api/verify", headers: {"X-Forwarded-Host" => "app.example.com"}
|
||||||
@@ -101,7 +101,7 @@ module Api
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "should match exact domains correctly" do
|
test "should match exact domains correctly" do
|
||||||
Application.create!(name: "Exact App", slug: "exact-app", app_type: "forward_auth", domain_pattern: "api.example.com", active: true)
|
grant_everyone_access Application.create!(name: "Exact App", slug: "exact-app", app_type: "forward_auth", domain_pattern: "api.example.com", active: true)
|
||||||
sign_in_as(@user)
|
sign_in_as(@user)
|
||||||
|
|
||||||
get "/api/verify", headers: {"X-Forwarded-Host" => "api.example.com"}
|
get "/api/verify", headers: {"X-Forwarded-Host" => "api.example.com"}
|
||||||
@@ -126,7 +126,7 @@ module Api
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "should return custom headers when configured" do
|
test "should return custom headers when configured" do
|
||||||
Application.create!(
|
grant_everyone_access Application.create!(
|
||||||
name: "Custom App",
|
name: "Custom App",
|
||||||
slug: "custom-app",
|
slug: "custom-app",
|
||||||
app_type: "forward_auth",
|
app_type: "forward_auth",
|
||||||
@@ -151,7 +151,7 @@ module Api
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "should return no headers when all headers disabled" do
|
test "should return no headers when all headers disabled" do
|
||||||
Application.create!(
|
grant_everyone_access Application.create!(
|
||||||
name: "No Headers App",
|
name: "No Headers App",
|
||||||
slug: "no-headers-app",
|
slug: "no-headers-app",
|
||||||
app_type: "forward_auth",
|
app_type: "forward_auth",
|
||||||
@@ -182,11 +182,19 @@ module Api
|
|||||||
assert_includes groups_header, "Editors"
|
assert_includes groups_header, "Editors"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should not include groups header when user has no groups" do
|
test "should not include groups header when user has no groups beyond the granting one and groups header empty" do
|
||||||
@user.groups.clear # Remove fixture groups
|
# Under default-deny the user must be in at least one group to access the app.
|
||||||
|
# This rewritten test verifies that when an app's headers_config disables the
|
||||||
|
# groups header, no x-remote-groups is sent regardless of memberships.
|
||||||
|
app = grant_everyone_access Application.create!(
|
||||||
|
name: "Headers Hidden", slug: "headers-hidden", app_type: "forward_auth",
|
||||||
|
domain_pattern: "hidden.example.com",
|
||||||
|
active: true,
|
||||||
|
headers_config: {groups: ""}
|
||||||
|
)
|
||||||
sign_in_as(@user)
|
sign_in_as(@user)
|
||||||
|
|
||||||
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
get "/api/verify", headers: {"X-Forwarded-Host" => "hidden.example.com"}
|
||||||
|
|
||||||
assert_response 200
|
assert_response 200
|
||||||
assert_nil response.headers["x-remote-groups"]
|
assert_nil response.headers["x-remote-groups"]
|
||||||
@@ -705,7 +713,7 @@ module Api
|
|||||||
class FaTokenHostBindingTest < ActionDispatch::IntegrationTest
|
class FaTokenHostBindingTest < ActionDispatch::IntegrationTest
|
||||||
setup do
|
setup do
|
||||||
@user = users(:bob)
|
@user = users(:bob)
|
||||||
Application.create!(name: "Bound App", slug: "bound-app", app_type: "forward_auth", domain_pattern: "app.example.com", active: true)
|
grant_everyone_access Application.create!(name: "Bound App", slug: "bound-app", app_type: "forward_auth", domain_pattern: "app.example.com", active: true)
|
||||||
|
|
||||||
@original_cache = Rails.cache
|
@original_cache = Rails.cache
|
||||||
Rails.cache = ActiveSupport::Cache::MemoryStore.new
|
Rails.cache = ActiveSupport::Cache::MemoryStore.new
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
@application.generate_new_client_secret!
|
@application.generate_new_client_secret!
|
||||||
@plain_client_secret = @application.client_secret
|
@plain_client_secret = @application.client_secret
|
||||||
@application.save!
|
@application.save!
|
||||||
|
grant_everyone_access(@application)
|
||||||
end
|
end
|
||||||
|
|
||||||
def teardown
|
def teardown
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
|||||||
redirect_uris: ["http://localhost:4000/callback"].to_json,
|
redirect_uris: ["http://localhost:4000/callback"].to_json,
|
||||||
active: true
|
active: true
|
||||||
)
|
)
|
||||||
|
grant_everyone_access(@application)
|
||||||
|
|
||||||
# Sign in the user using the test helper
|
# Sign in the user using the test helper
|
||||||
sign_in_as(@user)
|
sign_in_as(@user)
|
||||||
|
|||||||
6
test/fixtures/groups.yml
vendored
6
test/fixtures/groups.yml
vendored
@@ -11,7 +11,13 @@ two:
|
|||||||
admin_group:
|
admin_group:
|
||||||
name: Administrators
|
name: Administrators
|
||||||
description: System administrators with full access
|
description: System administrators with full access
|
||||||
|
admin: true
|
||||||
|
|
||||||
editor_group:
|
editor_group:
|
||||||
name: Editors
|
name: Editors
|
||||||
description: Content editors with limited access
|
description: Content editors with limited access
|
||||||
|
|
||||||
|
everyone:
|
||||||
|
name: everyone
|
||||||
|
description: Auto-assigned to new users.
|
||||||
|
auto_assign: true
|
||||||
|
|||||||
19
test/fixtures/user_groups.yml
vendored
19
test/fixtures/user_groups.yml
vendored
@@ -1,9 +1,28 @@
|
|||||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||||
|
# All users belong to "everyone" so existing tests that create group-less apps
|
||||||
|
# can be made compatible by attaching that group.
|
||||||
|
|
||||||
|
one_everyone:
|
||||||
|
user: one
|
||||||
|
group: everyone
|
||||||
|
two_everyone:
|
||||||
|
user: two
|
||||||
|
group: everyone
|
||||||
|
alice_everyone:
|
||||||
|
user: alice
|
||||||
|
group: everyone
|
||||||
|
bob_everyone:
|
||||||
|
user: bob
|
||||||
|
group: everyone
|
||||||
|
|
||||||
alice_admin_group:
|
alice_admin_group:
|
||||||
user: alice
|
user: alice
|
||||||
group: admin_group
|
group: admin_group
|
||||||
|
|
||||||
|
two_admin_group:
|
||||||
|
user: two
|
||||||
|
group: admin_group
|
||||||
|
|
||||||
bob_editor_group:
|
bob_editor_group:
|
||||||
user: bob
|
user: bob
|
||||||
group: editor_group
|
group: editor_group
|
||||||
|
|||||||
4
test/fixtures/users.yml
vendored
4
test/fixtures/users.yml
vendored
@@ -3,23 +3,19 @@
|
|||||||
one:
|
one:
|
||||||
email_address: one@example.com
|
email_address: one@example.com
|
||||||
password_digest: <%= password_digest %>
|
password_digest: <%= password_digest %>
|
||||||
admin: false
|
|
||||||
status: 0 # active
|
status: 0 # active
|
||||||
|
|
||||||
two:
|
two:
|
||||||
email_address: two@example.com
|
email_address: two@example.com
|
||||||
password_digest: <%= password_digest %>
|
password_digest: <%= password_digest %>
|
||||||
admin: true
|
|
||||||
status: 0 # active
|
status: 0 # active
|
||||||
|
|
||||||
alice:
|
alice:
|
||||||
email_address: alice@example.com
|
email_address: alice@example.com
|
||||||
password_digest: <%= password_digest %>
|
password_digest: <%= password_digest %>
|
||||||
admin: true
|
|
||||||
status: 0 # active
|
status: 0 # active
|
||||||
|
|
||||||
bob:
|
bob:
|
||||||
email_address: bob@example.com
|
email_address: bob@example.com
|
||||||
password_digest: <%= password_digest %>
|
password_digest: <%= password_digest %>
|
||||||
admin: false
|
|
||||||
status: 0 # active
|
status: 0 # active
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest
|
|||||||
# End-to-End Authentication Flow Tests
|
# End-to-End Authentication Flow Tests
|
||||||
test "complete forward auth flow with default headers" do
|
test "complete forward auth flow with default headers" do
|
||||||
# Create an application with default headers
|
# Create an application with default headers
|
||||||
Application.create!(name: "App", slug: "app-system-test", app_type: "forward_auth", domain_pattern: "app.example.com", active: true)
|
grant_everyone_access Application.create!(name: "App", slug: "app-system-test", app_type: "forward_auth", domain_pattern: "app.example.com", active: true)
|
||||||
|
|
||||||
# Step 1: Unauthenticated request to protected resource
|
# Step 1: Unauthenticated request to protected resource
|
||||||
get "/api/verify", headers: {
|
get "/api/verify", headers: {
|
||||||
@@ -48,14 +48,14 @@ class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "multiple domain access with single session" do
|
test "multiple domain access with single session" do
|
||||||
# Create applications for different domains
|
# Create applications for different domains
|
||||||
Application.create!(name: "App Domain", slug: "app-domain", app_type: "forward_auth", domain_pattern: "app.example.com", active: true)
|
grant_everyone_access Application.create!(name: "App Domain", slug: "app-domain", app_type: "forward_auth", domain_pattern: "app.example.com", active: true)
|
||||||
Application.create!(
|
grant_everyone_access Application.create!(
|
||||||
name: "Grafana", slug: "grafana-system-test", app_type: "forward_auth",
|
name: "Grafana", slug: "grafana-system-test", app_type: "forward_auth",
|
||||||
domain_pattern: "grafana.example.com",
|
domain_pattern: "grafana.example.com",
|
||||||
active: true,
|
active: true,
|
||||||
headers_config: {user: "X-WEBAUTH-USER", email: "X-WEBAUTH-EMAIL"}
|
headers_config: {user: "X-WEBAUTH-USER", email: "X-WEBAUTH-EMAIL"}
|
||||||
)
|
)
|
||||||
Application.create!(
|
grant_everyone_access Application.create!(
|
||||||
name: "Metube", slug: "metube-system-test", app_type: "forward_auth",
|
name: "Metube", slug: "metube-system-test", app_type: "forward_auth",
|
||||||
domain_pattern: "metube.example.com",
|
domain_pattern: "metube.example.com",
|
||||||
active: true,
|
active: true,
|
||||||
@@ -106,7 +106,7 @@ class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest
|
|||||||
# Should have access (in allowed group)
|
# Should have access (in allowed group)
|
||||||
get "/api/verify", headers: {"X-Forwarded-Host" => "admin.example.com"}
|
get "/api/verify", headers: {"X-Forwarded-Host" => "admin.example.com"}
|
||||||
assert_response 200
|
assert_response 200
|
||||||
assert_equal @group.name, response.headers["x-remote-groups"]
|
assert_includes response.headers["x-remote-groups"], @group.name
|
||||||
|
|
||||||
# Add user to second group
|
# Add user to second group
|
||||||
@user.groups << @group2
|
@user.groups << @group2
|
||||||
@@ -126,31 +126,27 @@ class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest
|
|||||||
assert_response 403
|
assert_response 403
|
||||||
end
|
end
|
||||||
|
|
||||||
test "bypass mode when no groups assigned to rule" do
|
test "default deny when no groups assigned to rule" do
|
||||||
# Create bypass application (no groups)
|
# An app with no allowed_groups now denies all users (was: bypass mode).
|
||||||
Application.create!(
|
Application.create!(
|
||||||
name: "Public", slug: "public-system-test", app_type: "forward_auth",
|
name: "No Groups", slug: "nogroups-system-test", app_type: "forward_auth",
|
||||||
domain_pattern: "public.example.com",
|
domain_pattern: "nogroups.example.com",
|
||||||
active: true
|
active: true
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create user with no groups
|
|
||||||
@user.groups.clear
|
@user.groups.clear
|
||||||
|
|
||||||
# Sign in
|
|
||||||
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
||||||
assert_response 303
|
assert_response 303
|
||||||
|
|
||||||
# Should have access (bypass mode)
|
get "/api/verify", headers: {"X-Forwarded-Host" => "nogroups.example.com"}
|
||||||
get "/api/verify", headers: {"X-Forwarded-Host" => "public.example.com"}
|
assert_response 403
|
||||||
assert_response 200
|
|
||||||
assert_equal @user.email_address, response.headers["x-remote-user"]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Security System Tests
|
# Security System Tests
|
||||||
test "session expiration and cleanup" do
|
test "session expiration and cleanup" do
|
||||||
# Create test application
|
# Create test application
|
||||||
Application.create!(
|
grant_everyone_access Application.create!(
|
||||||
name: "Test", slug: "test-system-test", app_type: "forward_auth",
|
name: "Test", slug: "test-system-test", app_type: "forward_auth",
|
||||||
domain_pattern: "test.example.com",
|
domain_pattern: "test.example.com",
|
||||||
active: true
|
active: true
|
||||||
@@ -179,7 +175,7 @@ class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "concurrent access with rate limiting considerations" do
|
test "concurrent access with rate limiting considerations" do
|
||||||
# Create wildcard application
|
# Create wildcard application
|
||||||
Application.create!(
|
grant_everyone_access Application.create!(
|
||||||
name: "Wildcard", slug: "wildcard-test", app_type: "forward_auth",
|
name: "Wildcard", slug: "wildcard-test", app_type: "forward_auth",
|
||||||
domain_pattern: "*.example.com",
|
domain_pattern: "*.example.com",
|
||||||
active: true
|
active: true
|
||||||
@@ -246,7 +242,11 @@ class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest
|
|||||||
active: true,
|
active: true,
|
||||||
headers_config: app[:headers_config]
|
headers_config: app[:headers_config]
|
||||||
)
|
)
|
||||||
|
if app[:groups].any?
|
||||||
app[:groups].each { |group| rule.allowed_groups << group }
|
app[:groups].each { |group| rule.allowed_groups << group }
|
||||||
|
else
|
||||||
|
grant_everyone_access(rule)
|
||||||
|
end
|
||||||
rule
|
rule
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -286,7 +286,7 @@ class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest
|
|||||||
]
|
]
|
||||||
|
|
||||||
patterns.each_with_index do |pattern_config, idx|
|
patterns.each_with_index do |pattern_config, idx|
|
||||||
Application.create!(
|
grant_everyone_access Application.create!(
|
||||||
name: "Pattern Test #{idx}", slug: "pattern-test-#{idx}", app_type: "forward_auth",
|
name: "Pattern Test #{idx}", slug: "pattern-test-#{idx}", app_type: "forward_auth",
|
||||||
domain_pattern: pattern_config[:pattern],
|
domain_pattern: pattern_config[:pattern],
|
||||||
active: true
|
active: true
|
||||||
@@ -310,7 +310,7 @@ class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest
|
|||||||
# Performance System Tests
|
# Performance System Tests
|
||||||
test "system performance under load" do
|
test "system performance under load" do
|
||||||
# Create test application with wildcard pattern
|
# Create test application with wildcard pattern
|
||||||
Application.create!(name: "Load Test", slug: "loadtest", app_type: "forward_auth", domain_pattern: "*.loadtest.example.com", active: true)
|
grant_everyone_access Application.create!(name: "Load Test", slug: "loadtest", app_type: "forward_auth", domain_pattern: "*.loadtest.example.com", active: true)
|
||||||
|
|
||||||
# Sign in
|
# Sign in
|
||||||
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
|
|||||||
domain_pattern: "test.example.com",
|
domain_pattern: "test.example.com",
|
||||||
active: true
|
active: true
|
||||||
)
|
)
|
||||||
|
grant_everyone_access(@test_app)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Basic Authentication Flow Tests
|
# Basic Authentication Flow Tests
|
||||||
@@ -56,8 +57,8 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
|
|||||||
# Domain and Rule Integration Tests
|
# Domain and Rule Integration Tests
|
||||||
test "different domain patterns with same session" do
|
test "different domain patterns with same session" do
|
||||||
# Create test rules
|
# Create test rules
|
||||||
Application.create!(name: "Wildcard App", slug: "wildcard-app", app_type: "forward_auth", domain_pattern: "*.example.com", active: true)
|
grant_everyone_access Application.create!(name: "Wildcard App", slug: "wildcard-app", app_type: "forward_auth", domain_pattern: "*.example.com", active: true)
|
||||||
Application.create!(name: "Exact App", slug: "exact-app", app_type: "forward_auth", domain_pattern: "api.example.com", active: true)
|
grant_everyone_access Application.create!(name: "Exact App", slug: "exact-app", app_type: "forward_auth", domain_pattern: "api.example.com", active: true)
|
||||||
|
|
||||||
# Sign in
|
# Sign in
|
||||||
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
||||||
@@ -103,14 +104,14 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
|
|||||||
# Header Configuration Integration Tests
|
# Header Configuration Integration Tests
|
||||||
test "different header configurations with same user" do
|
test "different header configurations with same user" do
|
||||||
# Create applications with different configs
|
# Create applications with different configs
|
||||||
Application.create!(name: "Default App", slug: "default-app", app_type: "forward_auth", domain_pattern: "default.example.com", active: true)
|
grant_everyone_access Application.create!(name: "Default App", slug: "default-app", app_type: "forward_auth", domain_pattern: "default.example.com", active: true)
|
||||||
Application.create!(
|
grant_everyone_access Application.create!(
|
||||||
name: "Custom App", slug: "custom-app", app_type: "forward_auth",
|
name: "Custom App", slug: "custom-app", app_type: "forward_auth",
|
||||||
domain_pattern: "custom.example.com",
|
domain_pattern: "custom.example.com",
|
||||||
active: true,
|
active: true,
|
||||||
headers_config: {user: "X-WEBAUTH-USER", groups: "X-WEBAUTH-ROLES"}
|
headers_config: {user: "X-WEBAUTH-USER", groups: "X-WEBAUTH-ROLES"}
|
||||||
)
|
)
|
||||||
Application.create!(
|
grant_everyone_access Application.create!(
|
||||||
name: "No Headers App", slug: "no-headers-app", app_type: "forward_auth",
|
name: "No Headers App", slug: "no-headers-app", app_type: "forward_auth",
|
||||||
domain_pattern: "noheaders.example.com",
|
domain_pattern: "noheaders.example.com",
|
||||||
active: true,
|
active: true,
|
||||||
@@ -196,7 +197,7 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
|
|||||||
admin_user = users(:two)
|
admin_user = users(:two)
|
||||||
|
|
||||||
# Create restricted rule
|
# Create restricted rule
|
||||||
Application.create!(
|
grant_everyone_access Application.create!(
|
||||||
name: "Admin App", slug: "admin-app", app_type: "forward_auth",
|
name: "Admin App", slug: "admin-app", app_type: "forward_auth",
|
||||||
domain_pattern: "admin.example.com",
|
domain_pattern: "admin.example.com",
|
||||||
active: true,
|
active: true,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ class ApiKeyTest < ActiveSupport::TestCase
|
|||||||
domain_pattern: "webdav.example.com",
|
domain_pattern: "webdav.example.com",
|
||||||
active: true
|
active: true
|
||||||
)
|
)
|
||||||
|
@app.allowed_groups << groups(:everyone)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "generates clk_ prefixed token on create" do
|
test "generates clk_ prefixed token on create" do
|
||||||
@@ -78,9 +79,8 @@ class ApiKeyTest < ActiveSupport::TestCase
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "validates user must have access to application" do
|
test "validates user must have access to application" do
|
||||||
group = groups(:admin_group)
|
# Restrict the app to admin_group only — bob is not in admin_group.
|
||||||
@app.allowed_groups << group
|
@app.allowed_groups = [groups(:admin_group)]
|
||||||
# @user (bob) is not in admin_group
|
|
||||||
key = @user.api_keys.build(name: "No Access", application: @app)
|
key = @user.api_keys.build(name: "No Access", application: @app)
|
||||||
assert_not key.valid?
|
assert_not key.valid?
|
||||||
assert_includes key.errors[:user], "does not have access to this application"
|
assert_includes key.errors[:user], "does not have access to this application"
|
||||||
|
|||||||
@@ -135,23 +135,36 @@ class UserTest < ActiveSupport::TestCase
|
|||||||
assert_equal user, found_user
|
assert_equal user, found_user
|
||||||
end
|
end
|
||||||
|
|
||||||
test "admin scope" do
|
test "admin scope returns users in admin groups" do
|
||||||
admin_user = User.create!(
|
admin_group = groups(:admin_group)
|
||||||
email_address: "admin@example.com",
|
admin_user = User.create!(email_address: "admin@example.com", password: "password123")
|
||||||
password: "password123",
|
admin_user.groups << admin_group
|
||||||
admin: true
|
regular_user = User.create!(email_address: "user@example.com", password: "password123")
|
||||||
)
|
|
||||||
regular_user = User.create!(
|
|
||||||
email_address: "user@example.com",
|
|
||||||
password: "password123",
|
|
||||||
admin: false
|
|
||||||
)
|
|
||||||
|
|
||||||
admins = User.admins
|
admins = User.admins
|
||||||
assert_includes admins, admin_user
|
assert_includes admins, admin_user
|
||||||
assert_not_includes admins, regular_user
|
assert_not_includes admins, regular_user
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "admin? reflects membership in any admin: true group" do
|
||||||
|
user = User.create!(email_address: "promoted@example.com", password: "password123")
|
||||||
|
assert_not user.admin?
|
||||||
|
user.groups << groups(:admin_group)
|
||||||
|
assert user.reload.admin?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "after_create auto-joins all auto_assign groups" do
|
||||||
|
user = User.create!(email_address: "newbie@example.com", password: "password123")
|
||||||
|
assert_includes user.groups, groups(:everyone)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "skip_auto_assign disables the after_create callback" do
|
||||||
|
user = User.new(email_address: "skipper@example.com", password: "password123")
|
||||||
|
user.skip_auto_assign = true
|
||||||
|
user.save!
|
||||||
|
assert_not_includes user.groups, groups(:everyone)
|
||||||
|
end
|
||||||
|
|
||||||
test "validates email address format" do
|
test "validates email address format" do
|
||||||
user = User.new(email_address: "invalid-email", password: "password123")
|
user = User.new(email_address: "invalid-email", password: "password123")
|
||||||
assert_not user.valid?
|
assert_not user.valid?
|
||||||
|
|||||||
@@ -95,7 +95,8 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "admin claim should not be included in token" do
|
test "admin claim should not be included in token" do
|
||||||
@user.update!(admin: true)
|
# alice is already in admin_group via fixtures, so admin? is true here
|
||||||
|
assert @user.admin?
|
||||||
|
|
||||||
token = @service.generate_id_token(@user, @application)
|
token = @service.generate_id_token(@user, @application)
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,15 @@ module SessionTestHelper
|
|||||||
Current.session&.destroy!
|
Current.session&.destroy!
|
||||||
cookies.delete("session_id")
|
cookies.delete("session_id")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Attach the auto-assign "everyone" group to the given app so existing tests
|
||||||
|
# written under the old "empty allowed_groups = public" rule keep working.
|
||||||
|
# New tests should attach groups explicitly to model real access intent.
|
||||||
|
def grant_everyone_access(app)
|
||||||
|
everyone = (groups(:everyone) rescue Group.find_by(auto_assign: true))
|
||||||
|
app.allowed_groups << everyone unless app.allowed_groups.include?(everyone)
|
||||||
|
app
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
ActiveSupport.on_load(:action_dispatch_integration_test) do
|
ActiveSupport.on_load(:action_dispatch_integration_test) do
|
||||||
|
|||||||
Reference in New Issue
Block a user