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

@@ -48,5 +48,23 @@ module Admin
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",
active: true
)
grant_everyone_access(@app)
@api_key = @user.api_keys.create!(name: "Test Key", application: @app)
@token = @api_key.plaintext_token
end

View File

@@ -7,8 +7,8 @@ module Api
@admin_user = users(:alice)
@inactive_user = User.create!(email_address: "inactive@example.com", password: "password", status: :disabled)
@group = groups(:admin_group)
@rule = 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)
@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 = grant_everyone_access(Application.create!(name: "Inactive App", slug: "inactive-app", app_type: "forward_auth", domain_pattern: "inactive.example.com", active: false))
end
# Authentication Tests
@@ -65,7 +65,7 @@ module Api
end
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
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
@@ -75,7 +75,7 @@ module Api
end
test "should return 200 when user is in allowed groups" do
@rule.allowed_groups << @group
@rule.allowed_groups = [@group]
@user.groups << @group
sign_in_as(@user)
@@ -86,7 +86,7 @@ module Api
# Domain Pattern Tests
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)
get "/api/verify", headers: {"X-Forwarded-Host" => "app.example.com"}
@@ -101,7 +101,7 @@ module Api
end
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)
get "/api/verify", headers: {"X-Forwarded-Host" => "api.example.com"}
@@ -126,7 +126,7 @@ module Api
end
test "should return custom headers when configured" do
Application.create!(
grant_everyone_access Application.create!(
name: "Custom App",
slug: "custom-app",
app_type: "forward_auth",
@@ -151,7 +151,7 @@ module Api
end
test "should return no headers when all headers disabled" do
Application.create!(
grant_everyone_access Application.create!(
name: "No Headers App",
slug: "no-headers-app",
app_type: "forward_auth",
@@ -182,11 +182,19 @@ module Api
assert_includes groups_header, "Editors"
end
test "should not include groups header when user has no groups" do
@user.groups.clear # Remove fixture groups
test "should not include groups header when user has no groups beyond the granting one and groups header empty" do
# 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)
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
get "/api/verify", headers: {"X-Forwarded-Host" => "hidden.example.com"}
assert_response 200
assert_nil response.headers["x-remote-groups"]
@@ -705,7 +713,7 @@ module Api
class FaTokenHostBindingTest < ActionDispatch::IntegrationTest
setup do
@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
Rails.cache = ActiveSupport::Cache::MemoryStore.new

View File

@@ -17,6 +17,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
@application.generate_new_client_secret!
@plain_client_secret = @application.client_secret
@application.save!
grant_everyone_access(@application)
end
def teardown

View File

@@ -10,6 +10,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
redirect_uris: ["http://localhost:4000/callback"].to_json,
active: true
)
grant_everyone_access(@application)
# Sign in the user using the test helper
sign_in_as(@user)