Massive refactor. Merge forward_auth into App, remove references to unimplemented OIDC federation and SAML features. Add group and user custom claims. Groups now allocate which apps a user can use
This commit is contained in:
11
test/fixtures/forward_auth_rules.yml
vendored
11
test/fixtures/forward_auth_rules.yml
vendored
@@ -1,11 +0,0 @@
|
||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||
|
||||
one:
|
||||
domain_pattern: MyString
|
||||
policy: 1
|
||||
active: false
|
||||
|
||||
two:
|
||||
domain_pattern: MyString
|
||||
policy: 1
|
||||
active: false
|
||||
@@ -1,210 +0,0 @@
|
||||
require "test_helper"
|
||||
|
||||
class OidcRoleMappingTest < ActionDispatch::IntegrationTest
|
||||
def setup
|
||||
@application = applications(:kavita_app)
|
||||
@user = users(:alice)
|
||||
|
||||
# Set a known client secret for testing
|
||||
@test_client_secret = "test_secret_for_testing_only"
|
||||
@application.client_secret = @test_client_secret
|
||||
@application.save!
|
||||
|
||||
@application.update!(
|
||||
role_mapping_mode: "oidc_managed",
|
||||
role_claim_name: "roles"
|
||||
)
|
||||
|
||||
@admin_role = @application.application_roles.create!(
|
||||
name: "admin",
|
||||
display_name: "Administrator"
|
||||
)
|
||||
@editor_role = @application.application_roles.create!(
|
||||
name: "editor",
|
||||
display_name: "Editor"
|
||||
)
|
||||
|
||||
sign_in @user
|
||||
end
|
||||
|
||||
test "should include roles in JWT tokens" do
|
||||
# Assign roles to user
|
||||
@application.assign_role_to_user!(@user, "admin", source: 'oidc')
|
||||
@application.assign_role_to_user!(@user, "editor", source: 'oidc')
|
||||
|
||||
# Get authorization code
|
||||
post oauth_authorize_path, params: {
|
||||
client_id: @application.client_id,
|
||||
response_type: "code",
|
||||
redirect_uri: "https://example.com/callback",
|
||||
scope: "openid profile email",
|
||||
state: "test-state",
|
||||
nonce: "test-nonce"
|
||||
}
|
||||
|
||||
follow_redirect!
|
||||
post oauth_consent_path, params: {
|
||||
consent: "approve",
|
||||
client_id: @application.client_id,
|
||||
redirect_uri: "https://example.com/callback",
|
||||
scope: "openid profile email",
|
||||
state: "test-state"
|
||||
}
|
||||
|
||||
assert_response :redirect
|
||||
authorization_code = extract_code_from_redirect(response.location)
|
||||
|
||||
# Exchange code for token
|
||||
post oauth_token_path, params: {
|
||||
grant_type: "authorization_code",
|
||||
code: authorization_code,
|
||||
redirect_uri: "https://example.com/callback",
|
||||
client_id: @application.client_id,
|
||||
client_secret: @test_client_secret
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
token_response = JSON.parse(response.body)
|
||||
id_token = token_response["id_token"]
|
||||
|
||||
# Decode and verify ID token contains roles
|
||||
decoded_token = JWT.decode(id_token, nil, false).first
|
||||
assert_includes decoded_token["roles"], "admin"
|
||||
assert_includes decoded_token["roles"], "editor"
|
||||
end
|
||||
|
||||
test "should filter roles by prefix" do
|
||||
@application.update!(role_prefix: "app-")
|
||||
@admin_role.update!(name: "app-admin")
|
||||
@editor_role.update!(name: "external-editor") # Should be filtered out
|
||||
|
||||
@application.assign_role_to_user!(@user, "app-admin", source: 'oidc')
|
||||
@application.assign_role_to_user!(@user, "external-editor", source: 'oidc')
|
||||
|
||||
# Get token
|
||||
post oauth_authorize_path, params: {
|
||||
client_id: @application.client_id,
|
||||
response_type: "code",
|
||||
redirect_uri: "https://example.com/callback",
|
||||
scope: "openid profile email",
|
||||
state: "test-state"
|
||||
}
|
||||
|
||||
follow_redirect!
|
||||
post oauth_consent_path, params: {
|
||||
consent: "approve",
|
||||
client_id: @application.client_id,
|
||||
redirect_uri: "https://example.com/callback",
|
||||
scope: "openid profile email",
|
||||
state: "test-state"
|
||||
}
|
||||
|
||||
authorization_code = extract_code_from_redirect(response.location)
|
||||
|
||||
post oauth_token_path, params: {
|
||||
grant_type: "authorization_code",
|
||||
code: authorization_code,
|
||||
redirect_uri: "https://example.com/callback",
|
||||
client_id: @application.client_id,
|
||||
client_secret: @test_client_secret
|
||||
}
|
||||
|
||||
token_response = JSON.parse(response.body)
|
||||
id_token = token_response["id_token"]
|
||||
decoded_token = JWT.decode(id_token, nil, false).first
|
||||
|
||||
assert_includes decoded_token["roles"], "app-admin"
|
||||
assert_not_includes decoded_token["roles"], "external-editor"
|
||||
end
|
||||
|
||||
test "should include role permissions when configured" do
|
||||
@application.update!(managed_permissions: { "include_permissions" => true })
|
||||
@admin_role.update!(permissions: { "read" => true, "write" => true, "delete" => true })
|
||||
|
||||
@application.assign_role_to_user!(@user, "admin", source: 'oidc')
|
||||
|
||||
# Get token and check for role permissions
|
||||
post oauth_authorize_path, params: {
|
||||
client_id: @application.client_id,
|
||||
response_type: "code",
|
||||
redirect_uri: "https://example.com/callback",
|
||||
scope: "openid profile email",
|
||||
state: "test-state"
|
||||
}
|
||||
|
||||
follow_redirect!
|
||||
post oauth_consent_path, params: {
|
||||
consent: "approve",
|
||||
client_id: @application.client_id,
|
||||
redirect_uri: "https://example.com/callback",
|
||||
scope: "openid profile email",
|
||||
state: "test-state"
|
||||
}
|
||||
|
||||
authorization_code = extract_code_from_redirect(response.location)
|
||||
|
||||
post oauth_token_path, params: {
|
||||
grant_type: "authorization_code",
|
||||
code: authorization_code,
|
||||
redirect_uri: "https://example.com/callback",
|
||||
client_id: @application.client_id,
|
||||
client_secret: @test_client_secret
|
||||
}
|
||||
|
||||
token_response = JSON.parse(response.body)
|
||||
id_token = token_response["id_token"]
|
||||
decoded_token = JWT.decode(id_token, nil, false).first
|
||||
|
||||
assert decoded_token["role_permissions"].present?
|
||||
role_permissions = decoded_token["role_permissions"].find { |rp| rp["name"] == "admin" }
|
||||
assert_equal({ "read" => true, "write" => true, "delete" => true }, role_permissions["permissions"])
|
||||
end
|
||||
|
||||
test "should use custom role claim name" do
|
||||
@application.update!(role_claim_name: "user_roles")
|
||||
@application.assign_role_to_user!(@user, "admin", source: 'oidc')
|
||||
|
||||
# Get token
|
||||
post oauth_authorize_path, params: {
|
||||
client_id: @application.client_id,
|
||||
response_type: "code",
|
||||
redirect_uri: "https://example.com/callback",
|
||||
scope: "openid profile email",
|
||||
state: "test-state"
|
||||
}
|
||||
|
||||
follow_redirect!
|
||||
post oauth_consent_path, params: {
|
||||
consent: "approve",
|
||||
client_id: @application.client_id,
|
||||
redirect_uri: "https://example.com/callback",
|
||||
scope: "openid profile email",
|
||||
state: "test-state"
|
||||
}
|
||||
|
||||
authorization_code = extract_code_from_redirect(response.location)
|
||||
|
||||
post oauth_token_path, params: {
|
||||
grant_type: "authorization_code",
|
||||
code: authorization_code,
|
||||
redirect_uri: "https://example.com/callback",
|
||||
client_id: @application.client_id,
|
||||
client_secret: @test_client_secret
|
||||
}
|
||||
|
||||
token_response = JSON.parse(response.body)
|
||||
id_token = token_response["id_token"]
|
||||
decoded_token = JWT.decode(id_token, nil, false).first
|
||||
|
||||
assert_nil decoded_token["roles"]
|
||||
assert_includes decoded_token["user_roles"], "admin"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def extract_code_from_redirect(redirect_url)
|
||||
uri = URI.parse(redirect_url)
|
||||
query_params = CGI.parse(uri.query)
|
||||
query_params["code"]&.first
|
||||
end
|
||||
end
|
||||
@@ -1,86 +0,0 @@
|
||||
require "test_helper"
|
||||
|
||||
class ApplicationRoleTest < ActiveSupport::TestCase
|
||||
def setup
|
||||
@application = applications(:kavita_app)
|
||||
@role = @application.application_roles.create!(
|
||||
name: "admin",
|
||||
display_name: "Administrator",
|
||||
description: "Full access to all features"
|
||||
)
|
||||
end
|
||||
|
||||
test "should be valid" do
|
||||
assert @role.valid?
|
||||
end
|
||||
|
||||
test "should require name" do
|
||||
@role.name = ""
|
||||
assert_not @role.valid?
|
||||
assert_includes @role.errors[:name], "can't be blank"
|
||||
end
|
||||
|
||||
test "should require display_name" do
|
||||
@role.display_name = ""
|
||||
assert_not @role.valid?
|
||||
assert_includes @role.errors[:display_name], "can't be blank"
|
||||
end
|
||||
|
||||
test "should enforce unique role name per application" do
|
||||
duplicate_role = @application.application_roles.build(
|
||||
name: @role.name,
|
||||
display_name: "Another Admin"
|
||||
)
|
||||
assert_not duplicate_role.valid?
|
||||
assert_includes duplicate_role.errors[:name], "has already been taken"
|
||||
end
|
||||
|
||||
test "should allow same role name in different applications" do
|
||||
other_app = Application.create!(
|
||||
name: "Other App",
|
||||
slug: "other-app",
|
||||
app_type: "oidc"
|
||||
)
|
||||
other_role = other_app.application_roles.build(
|
||||
name: @role.name,
|
||||
display_name: "Other Admin"
|
||||
)
|
||||
assert other_role.valid?
|
||||
end
|
||||
|
||||
test "should track user assignments" do
|
||||
user = users(:alice)
|
||||
assert_not @role.user_has_role?(user)
|
||||
|
||||
@role.assign_to_user!(user)
|
||||
assert @role.user_has_role?(user)
|
||||
assert @role.users.include?(user)
|
||||
end
|
||||
|
||||
test "should handle role removal" do
|
||||
user = users(:alice)
|
||||
@role.assign_to_user!(user)
|
||||
assert @role.user_has_role?(user)
|
||||
|
||||
@role.remove_from_user!(user)
|
||||
assert_not @role.user_has_role?(user)
|
||||
assert_not @role.users.include?(user)
|
||||
end
|
||||
|
||||
test "should default to active" do
|
||||
new_role = @application.application_roles.build(
|
||||
name: "member",
|
||||
display_name: "Member"
|
||||
)
|
||||
assert new_role.active?
|
||||
end
|
||||
|
||||
test "should support default permissions" do
|
||||
role_with_permissions = @application.application_roles.create!(
|
||||
name: "editor",
|
||||
display_name: "Editor",
|
||||
permissions: { "read" => true, "write" => true, "delete" => false }
|
||||
)
|
||||
assert_equal({ "read" => true, "write" => true, "delete" => false }, role_with_permissions.permissions)
|
||||
end
|
||||
end
|
||||
@@ -1,395 +0,0 @@
|
||||
require "test_helper"
|
||||
|
||||
class ForwardAuthRuleTest < ActiveSupport::TestCase
|
||||
def setup
|
||||
@rule = ForwardAuthRule.new(
|
||||
domain_pattern: "*.example.com",
|
||||
active: true
|
||||
)
|
||||
end
|
||||
|
||||
test "should be valid with valid attributes" do
|
||||
assert @rule.valid?
|
||||
end
|
||||
|
||||
test "should require domain_pattern" do
|
||||
@rule.domain_pattern = ""
|
||||
assert_not @rule.valid?
|
||||
assert_includes @rule.errors[:domain_pattern], "can't be blank"
|
||||
end
|
||||
|
||||
test "should require active to be boolean" do
|
||||
@rule.active = nil
|
||||
assert_not @rule.valid?
|
||||
assert_includes @rule.errors[:active], "is not included in the list"
|
||||
end
|
||||
|
||||
test "should normalize domain_pattern to lowercase" do
|
||||
@rule.domain_pattern = "*.EXAMPLE.COM"
|
||||
@rule.save!
|
||||
assert_equal "*.example.com", @rule.reload.domain_pattern
|
||||
end
|
||||
|
||||
test "should enforce unique domain_pattern" do
|
||||
@rule.save!
|
||||
duplicate = ForwardAuthRule.new(
|
||||
domain_pattern: "*.example.com",
|
||||
active: true
|
||||
)
|
||||
assert_not duplicate.valid?
|
||||
assert_includes duplicate.errors[:domain_pattern], "has already been taken"
|
||||
end
|
||||
|
||||
test "should match domain patterns correctly" do
|
||||
@rule.save!
|
||||
|
||||
assert @rule.matches_domain?("app.example.com")
|
||||
assert @rule.matches_domain?("api.example.com")
|
||||
assert @rule.matches_domain?("sub.app.example.com")
|
||||
assert_not @rule.matches_domain?("example.org")
|
||||
assert_not @rule.matches_domain?("otherexample.com")
|
||||
end
|
||||
|
||||
test "should handle exact domain matches" do
|
||||
@rule.domain_pattern = "api.example.com"
|
||||
@rule.save!
|
||||
|
||||
assert @rule.matches_domain?("api.example.com")
|
||||
assert_not @rule.matches_domain?("app.example.com")
|
||||
assert_not @rule.matches_domain?("sub.api.example.com")
|
||||
end
|
||||
|
||||
test "policy_for_user should return bypass when no groups assigned" do
|
||||
user = users(:one)
|
||||
@rule.save!
|
||||
|
||||
assert_equal "bypass", @rule.policy_for_user(user)
|
||||
end
|
||||
|
||||
test "policy_for_user should return deny for inactive rule" do
|
||||
user = users(:one)
|
||||
@rule.active = false
|
||||
@rule.save!
|
||||
|
||||
assert_equal "deny", @rule.policy_for_user(user)
|
||||
end
|
||||
|
||||
test "policy_for_user should return deny for inactive user" do
|
||||
user = users(:one)
|
||||
user.update!(active: false)
|
||||
@rule.save!
|
||||
|
||||
assert_equal "deny", @rule.policy_for_user(user)
|
||||
end
|
||||
|
||||
test "policy_for_user should return correct policy based on user groups and TOTP" do
|
||||
group = groups(:one)
|
||||
user_with_totp = users(:two)
|
||||
user_without_totp = users(:one)
|
||||
|
||||
user_with_totp.totp_secret = "test_secret"
|
||||
user_with_totp.save!
|
||||
|
||||
@rule.allowed_groups << group
|
||||
user_with_totp.groups << group
|
||||
user_without_totp.groups << group
|
||||
@rule.save!
|
||||
|
||||
assert_equal "two_factor", @rule.policy_for_user(user_with_totp)
|
||||
assert_equal "one_factor", @rule.policy_for_user(user_without_totp)
|
||||
end
|
||||
|
||||
test "user_allowed? should return true when no groups assigned" do
|
||||
user = users(:one)
|
||||
@rule.save!
|
||||
|
||||
assert @rule.user_allowed?(user)
|
||||
end
|
||||
|
||||
test "user_allowed? should return true when user in allowed groups" do
|
||||
group = groups(:one)
|
||||
user = users(:one)
|
||||
user.groups << group
|
||||
@rule.allowed_groups << group
|
||||
@rule.save!
|
||||
|
||||
assert @rule.user_allowed?(user)
|
||||
end
|
||||
|
||||
test "user_allowed? should return false when user not in allowed groups" do
|
||||
group = groups(:one)
|
||||
user = users(:one)
|
||||
@rule.allowed_groups << group
|
||||
@rule.save!
|
||||
|
||||
assert_not @rule.user_allowed?(user)
|
||||
end
|
||||
|
||||
# Header Configuration Tests
|
||||
test "effective_headers should return default headers when no custom config" do
|
||||
@rule.save!
|
||||
|
||||
expected = ForwardAuthRule::DEFAULT_HEADERS
|
||||
assert_equal expected, @rule.effective_headers
|
||||
end
|
||||
|
||||
test "effective_headers should merge custom headers with defaults" do
|
||||
@rule.save!
|
||||
@rule.update!(headers_config: { user: "X-Forwarded-User", email: "X-Forwarded-Email" })
|
||||
|
||||
expected = ForwardAuthRule::DEFAULT_HEADERS.merge(
|
||||
user: "X-Forwarded-User",
|
||||
email: "X-Forwarded-Email"
|
||||
)
|
||||
assert_equal expected, @rule.effective_headers
|
||||
end
|
||||
|
||||
test "headers_for_user should generate correct headers for user with groups" do
|
||||
group = groups(:one)
|
||||
user = users(:one)
|
||||
user.groups << group
|
||||
@rule.save!
|
||||
|
||||
headers = @rule.headers_for_user(user)
|
||||
|
||||
assert_equal user.email_address, headers["X-Remote-User"]
|
||||
assert_equal user.email_address, headers["X-Remote-Email"]
|
||||
assert_equal user.email_address, headers["X-Remote-Name"]
|
||||
assert_equal group.name, headers["X-Remote-Groups"]
|
||||
assert_equal "true", headers["X-Remote-Admin"]
|
||||
end
|
||||
|
||||
test "headers_for_user should generate correct headers for user without groups" do
|
||||
user = users(:one)
|
||||
@rule.save!
|
||||
|
||||
headers = @rule.headers_for_user(user)
|
||||
|
||||
assert_equal user.email_address, headers["X-Remote-User"]
|
||||
assert_equal user.email_address, headers["X-Remote-Email"]
|
||||
assert_equal user.email_address, headers["X-Remote-Name"]
|
||||
assert_nil headers["X-Remote-Groups"] # No groups, no header
|
||||
assert_equal "true", headers["X-Remote-Admin"]
|
||||
end
|
||||
|
||||
test "headers_for_user should work with custom headers" do
|
||||
user = users(:one)
|
||||
@rule.update!(headers_config: {
|
||||
user: "X-Forwarded-User",
|
||||
groups: "X-Custom-Groups"
|
||||
})
|
||||
|
||||
headers = @rule.headers_for_user(user)
|
||||
|
||||
assert_equal user.email_address, headers["X-Forwarded-User"]
|
||||
assert_nil headers["X-Remote-User"] # Should be overridden
|
||||
assert_equal user.email_address, headers["X-Remote-Email"] # Default preserved
|
||||
assert_nil headers["X-Custom-Groups"] # User has no groups
|
||||
end
|
||||
|
||||
test "headers_for_user should return empty hash when all headers disabled" do
|
||||
user = users(:one)
|
||||
@rule.update!(headers_config: {
|
||||
user: "",
|
||||
email: "",
|
||||
name: "",
|
||||
groups: "",
|
||||
admin: ""
|
||||
})
|
||||
|
||||
headers = @rule.headers_for_user(user)
|
||||
assert_empty headers
|
||||
end
|
||||
|
||||
test "headers_disabled? should correctly identify disabled headers" do
|
||||
@rule.save!
|
||||
assert_not @rule.headers_disabled?
|
||||
|
||||
@rule.update!(headers_config: { user: "X-Custom-User" })
|
||||
assert_not @rule.headers_disabled?
|
||||
|
||||
@rule.update!(headers_config: { user: "", email: "", name: "", groups: "", admin: "" })
|
||||
assert @rule.headers_disabled?
|
||||
end
|
||||
|
||||
# Additional Domain Pattern Tests
|
||||
test "matches_domain? should handle complex patterns" do
|
||||
@rule.save!
|
||||
|
||||
# Test multiple wildcards
|
||||
@rule.update!(domain_pattern: "*.*.example.com")
|
||||
assert @rule.matches_domain?("app.dev.example.com")
|
||||
assert @rule.matches_domain?("api.staging.example.com")
|
||||
assert_not @rule.matches_domain?("example.com")
|
||||
assert_not @rule.matches_domain?("app.example.org")
|
||||
|
||||
# Test exact domain with dots
|
||||
@rule.update!(domain_pattern: "api.v2.example.com")
|
||||
assert @rule.matches_domain?("api.v2.example.com")
|
||||
assert_not @rule.matches_domain?("api.v3.example.com")
|
||||
assert_not @rule.matches_domain?("v2.api.example.com")
|
||||
end
|
||||
|
||||
test "matches_domain? should handle case insensitivity" do
|
||||
@rule.update!(domain_pattern: "*.EXAMPLE.COM")
|
||||
@rule.save!
|
||||
|
||||
assert @rule.matches_domain?("app.example.com")
|
||||
assert @rule.matches_domain?("APP.EXAMPLE.COM")
|
||||
assert @rule.matches_domain?("App.Example.Com")
|
||||
end
|
||||
|
||||
test "matches_domain? should handle empty and nil domains" do
|
||||
@rule.save!
|
||||
|
||||
assert_not @rule.matches_domain?("")
|
||||
assert_not @rule.matches_domain?(nil)
|
||||
end
|
||||
|
||||
# Advanced Header Configuration Tests
|
||||
test "headers_for_user should handle partial header configuration" do
|
||||
user = users(:one)
|
||||
user.groups << groups(:one)
|
||||
@rule.update!(headers_config: {
|
||||
user: "X-Custom-User",
|
||||
email: "", # Disabled
|
||||
groups: "X-Custom-Groups"
|
||||
})
|
||||
@rule.save!
|
||||
|
||||
headers = @rule.headers_for_user(user)
|
||||
|
||||
# Should include custom user header
|
||||
assert_equal "X-Custom-User", headers.keys.find { |k| k.include?("User") }
|
||||
assert_equal user.email_address, headers["X-Custom-User"]
|
||||
|
||||
# Should include default email header (not overridden)
|
||||
assert_equal "X-Remote-Email", headers.keys.find { |k| k.include?("Email") }
|
||||
assert_equal user.email_address, headers["X-Remote-Email"]
|
||||
|
||||
# Should include custom groups header
|
||||
assert_equal "X-Custom-Groups", headers.keys.find { |k| k.include?("Groups") }
|
||||
assert_equal groups(:one).name, headers["X-Custom-Groups"]
|
||||
|
||||
# Should include default name header (not overridden)
|
||||
assert_equal "X-Remote-Name", headers.keys.find { |k| k.include?("Name") }
|
||||
end
|
||||
|
||||
test "headers_for_user should handle user without groups when groups header configured" do
|
||||
user = users(:one)
|
||||
user.groups.clear # No groups
|
||||
@rule.update!(headers_config: { groups: "X-Custom-Groups" })
|
||||
@rule.save!
|
||||
|
||||
headers = @rule.headers_for_user(user)
|
||||
|
||||
# Should not include groups header for user with no groups
|
||||
assert_nil headers["X-Custom-Groups"]
|
||||
assert_nil headers["X-Remote-Groups"]
|
||||
end
|
||||
|
||||
test "headers_for_user should handle non-admin user correctly" do
|
||||
user = users(:one)
|
||||
# Ensure user is not admin
|
||||
user.update!(admin: false)
|
||||
@rule.save!
|
||||
|
||||
headers = @rule.headers_for_user(user)
|
||||
|
||||
assert_equal "false", headers["X-Remote-Admin"]
|
||||
end
|
||||
|
||||
test "headers_for_user should work with nil headers_config" do
|
||||
user = users(:one)
|
||||
@rule.update!(headers_config: nil)
|
||||
@rule.save!
|
||||
|
||||
headers = @rule.headers_for_user(user)
|
||||
|
||||
# Should use default headers
|
||||
assert_equal "X-Remote-User", headers.keys.find { |k| k.include?("User") }
|
||||
assert_equal user.email_address, headers["X-Remote-User"]
|
||||
end
|
||||
|
||||
test "effective_headers should handle symbol keys in headers_config" do
|
||||
@rule.update!(headers_config: { user: "X-Symbol-User", email: "X-Symbol-Email" })
|
||||
@rule.save!
|
||||
|
||||
effective = @rule.effective_headers
|
||||
|
||||
assert_equal "X-Symbol-User", effective[:user]
|
||||
assert_equal "X-Symbol-Email", effective[:email]
|
||||
assert_equal "X-Remote-Name", effective[:name] # Default
|
||||
end
|
||||
|
||||
test "effective_headers should handle string keys in headers_config" do
|
||||
@rule.update!(headers_config: { "user" => "X-String-User", "email" => "X-String-Email" })
|
||||
@rule.save!
|
||||
|
||||
effective = @rule.effective_headers
|
||||
|
||||
assert_equal "X-String-User", effective[:user]
|
||||
assert_equal "X-String-Email", effective[:email]
|
||||
assert_equal "X-Remote-Name", effective[:name] # Default
|
||||
end
|
||||
|
||||
# Policy and Access Control Tests
|
||||
test "policy_for_user should handle user with TOTP enabled" do
|
||||
user = users(:one)
|
||||
user.update!(totp_secret: "test_secret")
|
||||
@rule.allowed_groups << groups(:one)
|
||||
user.groups << groups(:one)
|
||||
@rule.save!
|
||||
|
||||
policy = @rule.policy_for_user(user)
|
||||
assert_equal "two_factor", policy
|
||||
end
|
||||
|
||||
test "policy_for_user should handle user without TOTP" do
|
||||
user = users(:one)
|
||||
user.update!(totp_secret: nil)
|
||||
@rule.allowed_groups << groups(:one)
|
||||
user.groups << groups(:one)
|
||||
@rule.save!
|
||||
|
||||
policy = @rule.policy_for_user(user)
|
||||
assert_equal "one_factor", policy
|
||||
end
|
||||
|
||||
test "policy_for_user should handle user with multiple groups" do
|
||||
user = users(:one)
|
||||
group1 = groups(:one)
|
||||
group2 = groups(:two)
|
||||
@rule.allowed_groups << group1
|
||||
@rule.allowed_groups << group2
|
||||
user.groups << group1
|
||||
@rule.save!
|
||||
|
||||
policy = @rule.policy_for_user(user)
|
||||
assert_equal "one_factor", policy
|
||||
end
|
||||
|
||||
test "user_allowed? should handle user with multiple groups, one allowed" do
|
||||
user = users(:one)
|
||||
allowed_group = groups(:one)
|
||||
other_group = groups(:two)
|
||||
@rule.allowed_groups << allowed_group
|
||||
user.groups << allowed_group
|
||||
user.groups << other_group
|
||||
@rule.save!
|
||||
|
||||
assert @rule.user_allowed?(user)
|
||||
end
|
||||
|
||||
test "user_allowed? should handle user with multiple groups, none allowed" do
|
||||
user = users(:one)
|
||||
group1 = groups(:one)
|
||||
group2 = groups(:two)
|
||||
# Don't add any groups to allowed_groups
|
||||
user.groups << group1
|
||||
user.groups << group2
|
||||
@rule.save!
|
||||
|
||||
assert_not @rule.user_allowed?(user)
|
||||
end
|
||||
end
|
||||
@@ -1,87 +0,0 @@
|
||||
require "test_helper"
|
||||
|
||||
class UserRoleAssignmentTest < ActiveSupport::TestCase
|
||||
def setup
|
||||
@application = applications(:kavita_app)
|
||||
@role = @application.application_roles.create!(
|
||||
name: "admin",
|
||||
display_name: "Administrator"
|
||||
)
|
||||
@user = users(:alice)
|
||||
@assignment = UserRoleAssignment.create!(
|
||||
user: @user,
|
||||
application_role: @role
|
||||
)
|
||||
end
|
||||
|
||||
test "should be valid" do
|
||||
assert @assignment.valid?
|
||||
end
|
||||
|
||||
test "should enforce unique user-role combination" do
|
||||
duplicate_assignment = UserRoleAssignment.new(
|
||||
user: @user,
|
||||
application_role: @role
|
||||
)
|
||||
assert_not duplicate_assignment.valid?
|
||||
assert_includes duplicate_assignment.errors[:user], "has already been taken"
|
||||
end
|
||||
|
||||
test "should allow same user with different roles" do
|
||||
other_role = @application.application_roles.create!(
|
||||
name: "editor",
|
||||
display_name: "Editor"
|
||||
)
|
||||
other_assignment = UserRoleAssignment.new(
|
||||
user: @user,
|
||||
application_role: other_role
|
||||
)
|
||||
assert other_assignment.valid?
|
||||
end
|
||||
|
||||
test "should allow same role for different users" do
|
||||
other_user = users(:bob)
|
||||
other_assignment = UserRoleAssignment.new(
|
||||
user: other_user,
|
||||
application_role: @role
|
||||
)
|
||||
assert other_assignment.valid?
|
||||
end
|
||||
|
||||
test "should validate source" do
|
||||
@assignment.source = "invalid_source"
|
||||
assert_not @assignment.valid?
|
||||
assert_includes @assignment.errors[:source], "is not included in the list"
|
||||
end
|
||||
|
||||
test "should support valid sources" do %w[oidc manual group_sync].each do |source|
|
||||
@assignment.source = source
|
||||
assert @assignment.valid?, "Source '#{source}' should be valid"
|
||||
end
|
||||
end
|
||||
|
||||
test "should default to oidc source" do
|
||||
new_assignment = UserRoleAssignment.new(
|
||||
user: @user,
|
||||
application_role: @role
|
||||
)
|
||||
assert_equal "oidc", new_assignment.source
|
||||
end
|
||||
|
||||
test "should support metadata" do
|
||||
metadata = { "synced_at" => Time.current, "external_source" => "authentik" }
|
||||
@assignment.metadata = metadata
|
||||
@assignment.save
|
||||
assert_equal metadata, @assignment.reload.metadata
|
||||
end
|
||||
|
||||
test "should identify oidc managed assignments" do
|
||||
@assignment.source = "oidc"
|
||||
assert @assignment.sync_from_oidc?
|
||||
end
|
||||
|
||||
test "should not identify manually managed assignments as oidc" do
|
||||
@assignment.source = "manual"
|
||||
assert_not @assignment.sync_from_oidc?
|
||||
end
|
||||
end
|
||||
@@ -207,5 +207,4 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
||||
end
|
||||
assert_match /no key found/, error.message, "Should warn about missing private key"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,163 +0,0 @@
|
||||
require "test_helper"
|
||||
|
||||
class RoleMappingEngineTest < ActiveSupport::TestCase
|
||||
def setup
|
||||
@application = applications(:kavita_app)
|
||||
@user = users(:alice)
|
||||
@application.update!(
|
||||
role_mapping_mode: "oidc_managed",
|
||||
role_claim_name: "roles"
|
||||
)
|
||||
|
||||
@admin_role = @application.application_roles.create!(
|
||||
name: "admin",
|
||||
display_name: "Administrator"
|
||||
)
|
||||
@editor_role = @application.application_roles.create!(
|
||||
name: "editor",
|
||||
display_name: "Editor"
|
||||
)
|
||||
end
|
||||
|
||||
test "should sync user roles from claims" do
|
||||
claims = { "roles" => ["admin", "editor"] }
|
||||
|
||||
RoleMappingEngine.sync_user_roles!(@user, @application, claims)
|
||||
|
||||
assert @application.user_has_role?(@user, "admin")
|
||||
assert @application.user_has_role?(@user, "editor")
|
||||
end
|
||||
|
||||
test "should remove roles not present in claims for oidc managed" do
|
||||
# Assign initial roles
|
||||
@application.assign_role_to_user!(@user, "admin", source: 'oidc')
|
||||
@application.assign_role_to_user!(@user, "editor", source: 'oidc')
|
||||
|
||||
# Sync with only admin role
|
||||
claims = { "roles" => ["admin"] }
|
||||
RoleMappingEngine.sync_user_roles!(@user, @application, claims)
|
||||
|
||||
assert @application.user_has_role?(@user, "admin")
|
||||
assert_not @application.user_has_role?(@user, "editor")
|
||||
end
|
||||
|
||||
test "should handle hybrid mode role sync" do
|
||||
@application.update!(role_mapping_mode: "hybrid")
|
||||
|
||||
# Assign manual role first
|
||||
@application.assign_role_to_user!(@user, "editor", source: 'manual')
|
||||
|
||||
# Sync with admin role from OIDC
|
||||
claims = { "roles" => ["admin"] }
|
||||
RoleMappingEngine.sync_user_roles!(@user, @application, claims)
|
||||
|
||||
assert @application.user_has_role?(@user, "admin")
|
||||
assert @application.user_has_role?(@user, "editor") # Manual role preserved
|
||||
end
|
||||
|
||||
test "should filter roles by prefix" do
|
||||
@application.update!(role_prefix: "app-")
|
||||
@admin_role.update!(name: "app-admin")
|
||||
@editor_role.update!(name: "app-editor")
|
||||
|
||||
# Create non-matching role
|
||||
external_role = @application.application_roles.create!(
|
||||
name: "external-role",
|
||||
display_name: "External"
|
||||
)
|
||||
|
||||
claims = { "roles" => ["app-admin", "app-editor", "external-role"] }
|
||||
RoleMappingEngine.sync_user_roles!(@user, @application, claims)
|
||||
|
||||
assert @application.user_has_role?(@user, "app-admin")
|
||||
assert @application.user_has_role?(@user, "app-editor")
|
||||
assert_not @application.user_has_role?(@user, "external-role")
|
||||
end
|
||||
|
||||
test "should handle different claim names" do
|
||||
@application.update!(role_claim_name: "groups")
|
||||
claims = { "groups" => ["admin", "editor"] }
|
||||
|
||||
RoleMappingEngine.sync_user_roles!(@user, @application, claims)
|
||||
|
||||
assert @application.user_has_role?(@user, "admin")
|
||||
assert @application.user_has_role?(@user, "editor")
|
||||
end
|
||||
|
||||
test "should handle microsoft role claim format" do
|
||||
microsoft_claim = "http://schemas.microsoft.com/ws/2008/06/identity/claims/role"
|
||||
claims = { microsoft_claim => ["admin", "editor"] }
|
||||
|
||||
RoleMappingEngine.sync_user_roles!(@user, @application, claims)
|
||||
|
||||
assert @application.user_has_role?(@user, "admin")
|
||||
assert @application.user_has_role?(@user, "editor")
|
||||
end
|
||||
|
||||
test "should determine user access based on roles" do
|
||||
# OIDC managed mode - user needs roles to access
|
||||
claims = { "roles" => ["admin"] }
|
||||
assert RoleMappingEngine.user_allowed_with_roles?(@user, @application, claims)
|
||||
|
||||
# No roles should deny access
|
||||
empty_claims = { "roles" => [] }
|
||||
assert_not RoleMappingEngine.user_allowed_with_roles?(@user, @application, empty_claims)
|
||||
end
|
||||
|
||||
test "should handle hybrid mode access control" do
|
||||
@application.update!(role_mapping_mode: "hybrid")
|
||||
|
||||
# User with group access should be allowed
|
||||
group_access = @application.user_allowed?(@user)
|
||||
assert RoleMappingEngine.user_allowed_with_roles?(@user, @application)
|
||||
|
||||
# User with role access should be allowed
|
||||
claims = { "roles" => ["admin"] }
|
||||
assert RoleMappingEngine.user_allowed_with_roles?(@user, @application, claims)
|
||||
|
||||
# User without either should be denied
|
||||
empty_claims = { "roles" => [] }
|
||||
result = RoleMappingEngine.user_allowed_with_roles?(@user, @application, empty_claims)
|
||||
# Should be allowed if group access exists, otherwise denied
|
||||
assert_equal group_access, result
|
||||
end
|
||||
|
||||
test "should map external roles to internal roles" do
|
||||
external_roles = ["admin", "editor", "unknown-role"]
|
||||
|
||||
mapped_roles = RoleMappingEngine.map_external_to_internal_roles(@application, external_roles)
|
||||
|
||||
assert_includes mapped_roles, "admin"
|
||||
assert_includes mapped_roles, "editor"
|
||||
assert_not_includes mapped_roles, "unknown-role"
|
||||
end
|
||||
|
||||
test "should extract roles from various claim formats" do
|
||||
# Array format
|
||||
claims_array = { "roles" => ["admin", "editor"] }
|
||||
roles = RoleMappingEngine.send(:extract_roles_from_claims, @application, claims_array)
|
||||
assert_equal ["admin", "editor"], roles
|
||||
|
||||
# String format
|
||||
claims_string = { "roles" => "admin" }
|
||||
roles = RoleMappingEngine.send(:extract_roles_from_claims, @application, claims_string)
|
||||
assert_equal ["admin"], roles
|
||||
|
||||
# No roles
|
||||
claims_empty = { "other_claim" => "value" }
|
||||
roles = RoleMappingEngine.send(:extract_roles_from_claims, @application, claims_empty)
|
||||
assert_equal [], roles
|
||||
end
|
||||
|
||||
test "should handle disabled role mapping" do
|
||||
@application.update!(role_mapping_mode: "disabled")
|
||||
claims = { "roles" => ["admin"] }
|
||||
|
||||
# Should not sync roles when disabled
|
||||
RoleMappingEngine.sync_user_roles!(@user, @application, claims)
|
||||
assert_not @application.user_has_role?(@user, "admin")
|
||||
|
||||
# Should fall back to regular access control
|
||||
assert RoleMappingEngine.user_allowed_with_roles?(@user, @application, claims)
|
||||
end
|
||||
end
|
||||
@@ -1,96 +0,0 @@
|
||||
#!/usr/bin/env ruby
|
||||
|
||||
# Simple test script to verify role mapping functionality
|
||||
# Run with: ruby test/simple_role_test.rb
|
||||
|
||||
require_relative "../config/environment"
|
||||
|
||||
puts "🧪 Testing OIDC Role Mapping functionality..."
|
||||
|
||||
begin
|
||||
# Create test user
|
||||
user = User.create!(
|
||||
email_address: "test#{Time.current.to_i}@example.com",
|
||||
password: "password123",
|
||||
admin: false,
|
||||
status: :active
|
||||
)
|
||||
puts "✅ Created test user: #{user.email_address}"
|
||||
|
||||
# Create test application
|
||||
application = Application.create!(
|
||||
name: "Test Role App",
|
||||
slug: "test-role-app-#{Time.current.to_i}",
|
||||
app_type: "oidc",
|
||||
role_mapping_mode: "oidc_managed"
|
||||
)
|
||||
puts "✅ Created test application: #{application.name}"
|
||||
|
||||
# Create role
|
||||
role = application.application_roles.create!(
|
||||
name: "admin",
|
||||
display_name: "Administrator",
|
||||
description: "Full access role"
|
||||
)
|
||||
puts "✅ Created role: #{role.name}"
|
||||
|
||||
# Test role assignment
|
||||
application.assign_role_to_user!(user, "admin", source: 'manual')
|
||||
puts "✅ Assigned role to user"
|
||||
|
||||
# Verify role assignment
|
||||
unless application.user_has_role?(user, "admin")
|
||||
raise "Role should be assigned to user"
|
||||
end
|
||||
puts "✅ Verified role assignment"
|
||||
|
||||
# Test role mapping engine
|
||||
claims = { "roles" => ["admin", "editor"] }
|
||||
RoleMappingEngine.sync_user_roles!(user, application, claims)
|
||||
puts "✅ Synced roles from OIDC claims"
|
||||
|
||||
# Test JWT generation with roles
|
||||
token = OidcJwtService.generate_id_token(user, application)
|
||||
decoded = JWT.decode(token, nil, false).first
|
||||
unless decoded["roles"]&.include?("admin")
|
||||
raise "JWT should contain roles"
|
||||
end
|
||||
puts "✅ JWT includes roles claim"
|
||||
|
||||
# Test custom claim name
|
||||
application.update!(role_claim_name: "user_roles")
|
||||
token = OidcJwtService.generate_id_token(user, application)
|
||||
decoded = JWT.decode(token, nil, false).first
|
||||
unless decoded["user_roles"]&.include?("admin")
|
||||
raise "JWT should use custom claim name"
|
||||
end
|
||||
puts "✅ Custom claim name works"
|
||||
|
||||
# Test role prefix filtering
|
||||
application.update!(role_prefix: "app-")
|
||||
role.update!(name: "app-admin")
|
||||
application.assign_role_to_user!(user, "app-admin", source: 'manual')
|
||||
|
||||
claims = { "roles" => ["app-admin", "external-role"] }
|
||||
RoleMappingEngine.sync_user_roles!(user, application, claims)
|
||||
unless application.user_has_role?(user, "app-admin")
|
||||
raise "Prefixed role should be assigned"
|
||||
end
|
||||
if application.user_has_role?(user, "external-role")
|
||||
raise "Non-prefixed role should be filtered"
|
||||
end
|
||||
puts "✅ Role prefix filtering works"
|
||||
|
||||
# Cleanup
|
||||
user.destroy
|
||||
application.destroy
|
||||
puts "🧹 Cleaned up test data"
|
||||
|
||||
puts "\n🎉 All tests passed! OIDC Role Mapping is working correctly."
|
||||
|
||||
rescue => e
|
||||
puts "❌ Test failed: #{e.message}"
|
||||
puts e.backtrace.first(5)
|
||||
exit 1
|
||||
end
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
require "test_helper"
|
||||
|
||||
class RoleMappingTest < ActiveSupport::TestCase
|
||||
self.use_transactional_tests = true
|
||||
|
||||
# Don't load any fixtures
|
||||
def self.fixtures :all
|
||||
# Disable fixtures
|
||||
end
|
||||
# Test without fixtures for simplicity
|
||||
def setup
|
||||
@user = User.create!(
|
||||
email_address: "test@example.com",
|
||||
password: "password123",
|
||||
admin: false,
|
||||
status: :active
|
||||
)
|
||||
|
||||
@application = Application.create!(
|
||||
name: "Test App",
|
||||
slug: "test-app",
|
||||
app_type: "oidc"
|
||||
)
|
||||
|
||||
@admin_role = @application.application_roles.create!(
|
||||
name: "admin",
|
||||
display_name: "Administrator",
|
||||
description: "Full access user"
|
||||
)
|
||||
end
|
||||
|
||||
def teardown
|
||||
UserRoleAssignment.delete_all
|
||||
ApplicationRole.delete_all
|
||||
Application.delete_all
|
||||
User.delete_all
|
||||
end
|
||||
|
||||
test "should create application role" do
|
||||
assert @admin_role.valid?
|
||||
assert @admin_role.active?
|
||||
assert_equal "Administrator", @admin_role.display_name
|
||||
end
|
||||
|
||||
test "should assign role to user" do
|
||||
assert_not @application.user_has_role?(@user, "admin")
|
||||
|
||||
@application.assign_role_to_user!(@user, "admin", source: 'manual')
|
||||
|
||||
assert @application.user_has_role?(@user, "admin")
|
||||
assert @admin_role.user_has_role?(@user)
|
||||
end
|
||||
|
||||
test "should remove role from user" do
|
||||
@application.assign_role_to_user!(@user, "admin", source: 'manual')
|
||||
assert @application.user_has_role?(@user, "admin")
|
||||
|
||||
@application.remove_role_from_user!(@user, "admin")
|
||||
assert_not @application.user_has_role?(@user, "admin")
|
||||
end
|
||||
|
||||
test "should support role mapping modes" do
|
||||
assert_equal "disabled", @application.role_mapping_mode
|
||||
|
||||
@application.update!(role_mapping_mode: "oidc_managed")
|
||||
assert @application.role_mapping_enabled?
|
||||
assert @application.oidc_managed_roles?
|
||||
|
||||
@application.update!(role_mapping_mode: "hybrid")
|
||||
assert @application.hybrid_roles?
|
||||
end
|
||||
|
||||
test "should sync roles from OIDC claims" do
|
||||
@application.update!(role_mapping_mode: "oidc_managed")
|
||||
|
||||
claims = { "roles" => ["admin"] }
|
||||
RoleMappingEngine.sync_user_roles!(@user, @application, claims)
|
||||
|
||||
assert @application.user_has_role?(@user, "admin")
|
||||
end
|
||||
|
||||
test "should filter roles by prefix" do
|
||||
@application.update!(role_prefix: "app-")
|
||||
@admin_role.update!(name: "app-admin")
|
||||
|
||||
claims = { "roles" => ["app-admin", "external-role"] }
|
||||
RoleMappingEngine.sync_user_roles!(@user, @application, claims)
|
||||
|
||||
assert @application.user_has_role?(@user, "app-admin")
|
||||
end
|
||||
|
||||
test "should include roles in JWT tokens" do
|
||||
@application.assign_role_to_user!(@user, "admin", source: 'oidc')
|
||||
|
||||
token = OidcJwtService.generate_id_token(@user, @application)
|
||||
decoded = JWT.decode(token, nil, false).first
|
||||
|
||||
assert_includes decoded["roles"], "admin"
|
||||
end
|
||||
|
||||
test "should support custom role claim name" do
|
||||
@application.update!(role_claim_name: "user_roles")
|
||||
@application.assign_role_to_user!(@user, "admin", source: 'oidc')
|
||||
|
||||
token = OidcJwtService.generate_id_token(@user, @application)
|
||||
decoded = JWT.decode(token, nil, false).first
|
||||
|
||||
assert_includes decoded["user_roles"], "admin"
|
||||
assert_nil decoded["roles"]
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user