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>
This commit is contained in:
Dan Milne
2026-06-07 15:53:27 +10:00
parent 6b58b685c4
commit 03dfdbd83a
32 changed files with 530 additions and 88 deletions

View File

@@ -8,6 +8,12 @@ module Admin
def show
@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
def new

View File

@@ -122,7 +122,7 @@ module Admin
end
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

View File

@@ -7,27 +7,38 @@ module Admin
end
def show
@accessible_applications = Application.where(active: true)
.joins(:allowed_groups)
.where(groups: {id: @user.groups})
.distinct
.includes(:allowed_groups)
.order(:name)
end
def new
@user = User.new
@available_groups = Group.order(:name)
end
def create
@user = User.new(user_params)
@user.password = SecureRandom.alphanumeric(16) if user_params[:password].blank?
@user.status = :pending_invitation
@user.skip_auto_assign = true if params[:auto_assign] == "0"
if @user.save
assign_groups_from_params(@user)
InvitationsMailer.invite_user(@user).deliver_later
redirect_to admin_users_path, notice: "User created successfully. Invitation email sent to #{@user.email_address}."
else
@available_groups = Group.order(:name)
render :new, status: :unprocessable_entity
end
end
def edit
@applications = Application.active.order(:name)
@available_groups = Group.order(:name)
end
def update
@@ -43,6 +54,7 @@ module Admin
rescue JSON::ParserError
@user.errors.add(:custom_claims, "must be valid JSON")
@applications = Application.active.order(:name)
@available_groups = Group.order(:name)
render :edit, status: :unprocessable_entity
return
end
@@ -52,9 +64,16 @@ module Admin
end
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."
else
@applications = Application.active.order(:name)
@available_groups = Group.order(:name)
render :edit, status: :unprocessable_entity
end
end
@@ -122,14 +141,28 @@ module Admin
end
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)
if params[:id] != Current.session.user.id.to_s
permitted << :admin
# 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
params.require(:user).permit(*permitted)
user.groups = new_groups
true
end
end
end

View File

@@ -8,12 +8,16 @@ class UsersController < ApplicationController
def create
@user = User.new(user_params)
# First user becomes admin automatically
@user.admin = true if User.count.zero?
@user.status = "active"
first_user = User.count.zero?
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
redirect_to root_path, notice: "Welcome to Clinch! Your account has been created."
else