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

@@ -118,14 +118,12 @@ class Application < ApplicationRecord
end
# 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)
return false unless 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?
end
@@ -168,10 +166,6 @@ class Application < ApplicationRecord
return "deny" unless 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)
# Require 2FA if user has TOTP configured, otherwise 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 }
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
def parsed_custom_claims
return {} if custom_claims.blank?
@@ -23,6 +28,13 @@ class Group < ApplicationRecord
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
return if custom_claims.blank?

View File

@@ -42,7 +42,18 @@ class User < ApplicationRecord
enum :status, {active: 0, disabled: 1, pending_invitation: 2}
# 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
def totp_enabled?
@@ -222,6 +233,10 @@ class User < ApplicationRecord
private
def add_to_auto_assign_groups
Group.auto_assign.each { |g| groups << g }
end
def no_reserved_claim_names
return if custom_claims.blank?