4 Commits

Author SHA1 Message Date
Dan Milne
03dfdbd83a Default-deny access control with group flags and access enumeration
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
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>
2026-06-07 15:53:27 +10:00
Dan Milne
6b58b685c4 Bump version to 0.12.0
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 21:18:35 +10:00
Dan Milne
a399907dfd Allow assigning applications to a group from the group form
Adds an "Assigned Applications" checkbox list to the group new/edit
form so admins can grant a group access to multiple apps from one
screen, instead of editing each application individually.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 21:17:43 +10:00
Dan Milne
bbfb564e1c Show Clinch, Rails and Ruby versions in sidebar footer; bump to 0.11.0
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 23:39:11 +10:00
34 changed files with 637 additions and 88 deletions

View File

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

View File

@@ -15,6 +15,7 @@ module Admin
def new def new
@group = Group.new @group = Group.new
@available_users = User.order(:email_address) @available_users = User.order(:email_address)
@available_applications = Application.order(:name)
end end
def create def create
@@ -28,6 +29,7 @@ module Admin
@group = Group.new @group = Group.new
@group.errors.add(:custom_claims, "must be valid JSON") @group.errors.add(:custom_claims, "must be valid JSON")
@available_users = User.order(:email_address) @available_users = User.order(:email_address)
@available_applications = Application.order(:name)
render :new, status: :unprocessable_entity render :new, status: :unprocessable_entity
return return
end end
@@ -45,15 +47,23 @@ module Admin
@group.users = User.where(id: user_ids) @group.users = User.where(id: user_ids)
end end
# Handle application assignments
if params[:group][:application_ids].present?
application_ids = params[:group][:application_ids].reject(&:blank?)
@group.applications = Application.where(id: application_ids)
end
redirect_to admin_group_path(@group), notice: "Group created successfully." redirect_to admin_group_path(@group), notice: "Group created successfully."
else else
@available_users = User.order(:email_address) @available_users = User.order(:email_address)
@available_applications = Application.order(:name)
render :new, status: :unprocessable_entity render :new, status: :unprocessable_entity
end end
end end
def edit def edit
@available_users = User.order(:email_address) @available_users = User.order(:email_address)
@available_applications = Application.order(:name)
end end
def update def update
@@ -66,6 +76,7 @@ module Admin
rescue JSON::ParserError rescue JSON::ParserError
@group.errors.add(:custom_claims, "must be valid JSON") @group.errors.add(:custom_claims, "must be valid JSON")
@available_users = User.order(:email_address) @available_users = User.order(:email_address)
@available_applications = Application.order(:name)
render :edit, status: :unprocessable_entity render :edit, status: :unprocessable_entity
return return
end end
@@ -83,9 +94,18 @@ module Admin
@group.users = [] @group.users = []
end end
# Handle application assignments
if params[:group][:application_ids].present?
application_ids = params[:group][:application_ids].reject(&:blank?)
@group.applications = Application.where(id: application_ids)
else
@group.applications = []
end
redirect_to admin_group_path(@group), notice: "Group updated successfully." redirect_to admin_group_path(@group), notice: "Group updated successfully."
else else
@available_users = User.order(:email_address) @available_users = User.order(:email_address)
@available_applications = Application.order(:name)
render :edit, status: :unprocessable_entity render :edit, status: :unprocessable_entity
end end
end end
@@ -102,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

View File

@@ -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)
end
# Only allow modifying admin status when editing other users (prevent self-demotion) # Apply group_ids from the form, with a guard preventing self-demotion when
if params[:id] != Current.session.user.id.to_s # the user is the last member of the last admin group. Returns true on
permitted << :admin # 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 end
params.require(:user).permit(*permitted) user.groups = new_groups
true
end end
end end
end end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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">
@@ -32,6 +48,29 @@
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Select which users should be members of this group.</p> <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Select which users should be members of this group.</p>
</div> </div>
<div>
<%= form.label :application_ids, "Assigned Applications", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<div class="mt-2 space-y-2 max-h-48 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-md p-3">
<% if @available_applications.any? %>
<% @available_applications.each do |application| %>
<div class="flex items-center">
<%= check_box_tag "group[application_ids][]", application.id, group.applications.include?(application), class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
<%= label_tag "group_application_ids_#{application.id}", application.name, class: "ml-2 text-sm text-gray-900 dark:text-gray-100" %>
<% case application.app_type %>
<% when "oidc" %>
<span class="ml-2 inline-flex items-center rounded-full bg-purple-100 dark:bg-purple-900/50 px-2 py-0.5 text-xs font-medium text-purple-700 dark:text-purple-300">OIDC</span>
<% when "trusted_header" %>
<span class="ml-2 inline-flex items-center rounded-full bg-indigo-100 dark:bg-indigo-900/50 px-2 py-0.5 text-xs font-medium text-indigo-700 dark:text-indigo-300">ForwardAuth</span>
<% end %>
</div>
<% end %>
<% else %>
<p class="text-sm text-gray-500 dark:text-gray-400">No applications available.</p>
<% end %>
</div>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Select which applications this group grants access to.</p>
</div>
<div data-controller="json-validator" data-json-validator-valid-class="border-green-500 focus:border-green-500 focus:ring-green-500" data-json-validator-invalid-class="border-red-500 focus:border-red-500 focus:ring-red-500" data-json-validator-valid-status-class="text-green-600" data-json-validator-invalid-status-class="text-red-600"> <div data-controller="json-validator" data-json-validator-valid-class="border-green-500 focus:border-green-500 focus:ring-green-500" data-json-validator-invalid-class="border-red-500 focus:border-red-500 focus:ring-red-500" data-json-validator-valid-status-class="text-green-600" data-json-validator-invalid-status-class="text-red-600">
<%= form.label :custom_claims, "Custom Claims (JSON)", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %> <%= form.label :custom_claims, "Custom Claims (JSON)", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.text_area :custom_claims, value: (group.custom_claims.present? ? JSON.pretty_generate(group.custom_claims) : ""), rows: 8, <%= form.text_area :custom_claims, value: (group.custom_claims.present? ? JSON.pretty_generate(group.custom_claims) : ""), rows: 8,

View File

@@ -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>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @group.name %></h1> <div class="flex items-center gap-2">
<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 %>

View File

@@ -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 class="flex items-center"> <div>
<%= 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) %> <%= form.label :group_ids, "Group Memberships", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.label :admin, "Administrator", class: "ml-2 block text-sm text-gray-900 dark:text-gray-100" %> <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 user == Current.session.user %> <% if @available_groups.any? %>
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">(Cannot change your own admin status)</span> <% @available_groups.each do |group| %>
<div class="flex items-center">
<%= 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" %>
<%= label_tag "user_group_ids_#{group.id}", group.name, class: "ml-2 text-sm text-gray-900 dark:text-gray-100" %>
<% if group.admin? %>
<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>

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

View File

@@ -115,6 +115,10 @@
</li> </li>
</ul> </ul>
</li> </li>
<li class="mt-auto pt-4 border-t border-gray-200 dark:border-gray-700">
<%= render "shared/version_info" %>
</li>
</ul> </ul>
</nav> </nav>
</div> </div>
@@ -233,6 +237,10 @@
<% end %> <% end %>
</li> </li>
</ul> </ul>
<div class="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
<%= render "shared/version_info" %>
</div>
</nav> </nav>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,4 @@
<div class="px-2 text-xs text-gray-500 dark:text-gray-500 space-y-0.5">
<div>Clinch <%= Clinch::VERSION %></div>
<div>Rails <%= Rails.version %> &middot; Ruby <%= RUBY_VERSION %></div>
</div>

View File

@@ -1,5 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
module Clinch module Clinch
VERSION = "0.10.2" VERSION = "0.13.0"
end end

View File

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

View File

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

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

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

View File

@@ -0,0 +1,70 @@
require "test_helper"
module Admin
class GroupsControllerTest < ActionDispatch::IntegrationTest
setup do
@admin = users(:two)
sign_in_as(@admin)
@group = groups(:one)
end
test "update assigns applications from application_ids" do
app_a = applications(:kavita_app)
app_b = applications(:another_app)
patch admin_group_path(@group), params: {
group: {
name: @group.name,
application_ids: [app_a.id, app_b.id]
}
}
assert_redirected_to admin_group_path(@group)
assert_equal [app_a, app_b].sort, @group.reload.applications.sort
end
test "update with no application_ids clears assigned applications" do
@group.applications = [applications(:kavita_app)]
patch admin_group_path(@group), params: {
group: { name: @group.name }
}
assert_redirected_to admin_group_path(@group)
assert_empty @group.reload.applications
end
test "create assigns applications from application_ids" do
app = applications(:audiobookshelf_app)
assert_difference -> { Group.count }, 1 do
post admin_groups_path, params: {
group: {
name: "New Group",
application_ids: [app.id]
}
}
end
assert_equal [app], Group.find_by(name: "new group").applications
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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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]
) )
app[:groups].each { |group| rule.allowed_groups << group } if app[:groups].any?
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"}

View File

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

View File

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

View File

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

View File

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

View File

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