diff --git a/README.md b/README.md index 099476f..05a6bb3 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ It introduces setup, verification, and management flows for user accounts inside - [User Flow](#user-flow) - [MFA User Management](#mfa-user-management) - [Configuration as Code (JCasC)](#configuration-as-code-jcasc) + - [MFA Exemptions](#mfa-exemptions) - [Security Notes](#security-notes) - [Development](#development) - [VS Code Debugging](#vs-code-debugging) @@ -36,6 +37,7 @@ It introduces setup, verification, and management flows for user accounts inside - Session-based MFA verification after primary login - Basic brute-force protection on code verification attempts - Supports Jenkins dark theme for all UI elements +- Configurable exemptions for service accounts and specific roles ## Requirements @@ -48,6 +50,8 @@ It introduces setup, verification, and management flows for user accounts inside 3. Configure: - `Issuer Name` - `Require MFA for all users` + - `Exempt Users` (optional) + - `Exempt Roles` (optional) 4. Save settings. 5. Ask users to enroll via the OpenMFA setup page before broad enforcement. @@ -105,8 +109,29 @@ security: openMFA: issuer: 'Jenkins' requireMFA: true + exemptUsers: + - svc-deploy + - break-glass + exemptRoles: + - ci-agents + - service-accounts ``` +## MFA Exemptions + +You can exempt specific users or roles from MFA requirements: + +- **Exempt Users**: Individual usernames that bypass MFA (case-insensitive) +- **Exempt Roles**: Group/role names that bypass MFA for all members + +Exemptions are useful for: + +- Service accounts used by CI/CD pipelines +- Emergency break-glass accounts +- Automated agents that cannot complete MFA + +**Note:** Exempted users bypass MFA entirely. Do not exempt regular user accounts. + ## Security Notes - Treat JCasC files and Jenkins configuration files as privileged, since they control authentication behavior. diff --git a/src/main/java/io/jenkins/plugins/openmfa/MFAFilter.java b/src/main/java/io/jenkins/plugins/openmfa/MFAFilter.java index c01737a..0ca31da 100644 --- a/src/main/java/io/jenkins/plugins/openmfa/MFAFilter.java +++ b/src/main/java/io/jenkins/plugins/openmfa/MFAFilter.java @@ -4,6 +4,7 @@ import hudson.model.User; import io.jenkins.plugins.openmfa.base.MFAContext; import io.jenkins.plugins.openmfa.constant.PluginConstants; +import io.jenkins.plugins.openmfa.service.ExemptionService; import io.jenkins.plugins.openmfa.service.SessionService; import io.jenkins.plugins.openmfa.util.JenkinsUtil; import io.jenkins.plugins.openmfa.util.SecurityUtil; @@ -59,6 +60,9 @@ public class MFAFilter implements Filter { PluginConstants.Urls.SECURITY_CHECK_ENDPOINT ); + private ExemptionService exemptionService = + MFAContext.i().getService(ExemptionService.class); + private SessionService sessionService = MFAContext.i().getService(SessionService.class); @@ -172,6 +176,13 @@ private boolean handleMFAVerification( throws IOException { String username = user.getId(); + + // Check if user is exempt from MFA + if (exemptionService.isExempt(user)) { + log.fine("User " + username + " is exempt from MFA, allowing access"); + return true; + } + if (!TOTPUtil.isMFAEnabled()) { if (TOTPUtil.isMFARequired()) { resp.sendRedirect( diff --git a/src/main/java/io/jenkins/plugins/openmfa/MFAGlobalConfiguration.java b/src/main/java/io/jenkins/plugins/openmfa/MFAGlobalConfiguration.java index e04477e..a7cbb7d 100644 --- a/src/main/java/io/jenkins/plugins/openmfa/MFAGlobalConfiguration.java +++ b/src/main/java/io/jenkins/plugins/openmfa/MFAGlobalConfiguration.java @@ -4,6 +4,7 @@ import hudson.Extension; import hudson.ExtensionList; import io.jenkins.plugins.openmfa.constant.UIConstants; +import java.util.concurrent.atomic.AtomicReference; import jenkins.model.GlobalConfiguration; import jenkins.model.GlobalConfigurationCategory; import lombok.Getter; @@ -23,14 +24,41 @@ @Symbol("openMFA") public class MFAGlobalConfiguration extends GlobalConfiguration { + /** + * Cached instance for fallback when ExtensionList is not available. + */ + private static final AtomicReference FALLBACK_INSTANCE = + new AtomicReference<>(); + /** * Get the singleton instance of this configuration. */ @NonNull public static MFAGlobalConfiguration get() { - return ExtensionList.lookupSingleton(MFAGlobalConfiguration.class); + // Try to get from ExtensionList first (works when Jenkins is fully initialized) + try { + return ExtensionList.lookupSingleton(MFAGlobalConfiguration.class); + } catch (IllegalStateException e) { + // Fallback: use a cached instance if ExtensionList is not available + // This can happen in some test scenarios + FALLBACK_INSTANCE.compareAndSet(null, new MFAGlobalConfiguration()); + return FALLBACK_INSTANCE.get(); + } + } + + /** + * Reset the fallback instance. For test use only. + */ + public static void resetFallbackInstance() { + FALLBACK_INSTANCE.set(null); } + /** Comma/newline-separated list of roles/groups exempt from MFA */ + private String exemptRoles = ""; + + /** Comma/newline-separated list of usernames exempt from MFA */ + private String exemptUsers = ""; + /** The issuer name shown in authenticator apps */ private String issuer = UIConstants.Defaults.DEFAULT_ISSUER; @@ -70,6 +98,32 @@ public String getDisplayName() { return Messages.DisplayNames_OPENMFA_GLOBAL_CONFIGURATION(); } + /** + * Get the list of exempt roles (for Jelly view). + * Returns one role per line for easier editing. + */ + public String getExemptRoles() { + return exemptRoles; + } + + /** + * Get the list of exempt users (for Jelly view). + * Returns one user per line for easier editing. + */ + public String getExemptUsers() { + return exemptUsers; + } + + @DataBoundSetter + public void setExemptRoles(String exemptRoles) { + this.exemptRoles = exemptRoles != null ? exemptRoles : ""; + } + + @DataBoundSetter + public void setExemptUsers(String exemptUsers) { + this.exemptUsers = exemptUsers != null ? exemptUsers : ""; + } + @DataBoundSetter public void setIssuer(String issuer) { this.issuer = diff --git a/src/main/java/io/jenkins/plugins/openmfa/MFAManagementLink.java b/src/main/java/io/jenkins/plugins/openmfa/MFAManagementLink.java index 5d7c858..149d575 100644 --- a/src/main/java/io/jenkins/plugins/openmfa/MFAManagementLink.java +++ b/src/main/java/io/jenkins/plugins/openmfa/MFAManagementLink.java @@ -133,7 +133,7 @@ public Category getCategory() { @Override public String getDescription() { - return Messages.MFAManagementLink_description(); + return Messages.MFAManagementLink_DESCRIPTION(); } @Override diff --git a/src/main/java/io/jenkins/plugins/openmfa/service/ExemptionService.java b/src/main/java/io/jenkins/plugins/openmfa/service/ExemptionService.java new file mode 100644 index 0000000..07b43b3 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/openmfa/service/ExemptionService.java @@ -0,0 +1,91 @@ +package io.jenkins.plugins.openmfa.service; + +import hudson.model.User; +import io.jenkins.plugins.openmfa.MFAGlobalConfiguration; +import io.jenkins.plugins.openmfa.base.Service; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import lombok.extern.java.Log; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; + +/** + * Service for checking if a user is exempt from MFA requirements. + * Exemptions can be configured by username or by role/group membership. + */ +@Log +@Service +public class ExemptionService { + + /** + * Checks if the given user is exempt from MFA requirements. + * A user is exempt if: + * - Their username is in the exempt users list (case-insensitive) + * - They have any role/group in the exempt roles list + * + * @param user + * the user to check + * @return true if the user is exempt, false otherwise + */ + public boolean isExempt(User user) { + if (user == null) { + return false; + } + + MFAGlobalConfiguration config = MFAGlobalConfiguration.get(); + + // Check username exemption + if (isExemptByUsername(user, config)) { + log.fine("User " + user.getId() + " is exempt by username"); + return true; + } + + // Check role exemption + if (isExemptByRole(user, config)) { + log.fine("User " + user.getId() + " is exempt by role"); + return true; + } + + return false; + } + + private boolean isExemptByRole(User user, MFAGlobalConfiguration config) { + List exemptRoles = parseList(config.getExemptRoles()); + if (exemptRoles.isEmpty()) { + return false; + } + + // Get user's authentication to check authorities + Authentication auth = user.impersonate2(); + + return auth.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .anyMatch( + role -> exemptRoles.stream() + .anyMatch(exempt -> exempt.equalsIgnoreCase(role)) + ); + } + + private boolean isExemptByUsername(User user, MFAGlobalConfiguration config) { + List exemptUsers = parseList(config.getExemptUsers()); + if (exemptUsers.isEmpty()) { + return false; + } + + String userId = user.getId().toLowerCase().trim(); + return exemptUsers.stream() + .anyMatch(exempt -> exempt.toLowerCase().trim().equals(userId)); + } + + private List parseList(String input) { + if (input == null || input.trim().isEmpty()) { + return Collections.emptyList(); + } + + return Arrays.stream(input.split("\n")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toList(); + } +} diff --git a/src/main/resources/io/jenkins/plugins/openmfa/MFAGlobalConfiguration/config.jelly b/src/main/resources/io/jenkins/plugins/openmfa/MFAGlobalConfiguration/config.jelly index 55ba0cd..8635e2a 100644 --- a/src/main/resources/io/jenkins/plugins/openmfa/MFAGlobalConfiguration/config.jelly +++ b/src/main/resources/io/jenkins/plugins/openmfa/MFAGlobalConfiguration/config.jelly @@ -14,6 +14,16 @@ + + + + ${%config.exemptUsers.description} + + + + + ${%config.exemptRoles.description} + diff --git a/src/main/resources/io/jenkins/plugins/openmfa/MFAGlobalConfiguration/config.properties b/src/main/resources/io/jenkins/plugins/openmfa/MFAGlobalConfiguration/config.properties index 3e4b536..197a898 100644 --- a/src/main/resources/io/jenkins/plugins/openmfa/MFAGlobalConfiguration/config.properties +++ b/src/main/resources/io/jenkins/plugins/openmfa/MFAGlobalConfiguration/config.properties @@ -2,3 +2,10 @@ config.section.title=OpenMFA Configuration config.requireMFA.title=Require MFA for all users config.issuer.title=Issuer Name config.mfaManagement.title=MFA User Management + +# Exemption configuration messages +config.exemptions.title=MFA Exemptions +config.exemptUsers.title=Exempt Users +config.exemptUsers.description=One username per line. Exempted users bypass MFA entirely. +config.exemptRoles.title=Exempt Roles/Groups +config.exemptRoles.description=One role per line. Users with these roles bypass MFA. diff --git a/src/main/resources/io/jenkins/plugins/openmfa/MFAGlobalConfiguration/config_es.properties b/src/main/resources/io/jenkins/plugins/openmfa/MFAGlobalConfiguration/config_es.properties index 453cd49..b6751a8 100644 --- a/src/main/resources/io/jenkins/plugins/openmfa/MFAGlobalConfiguration/config_es.properties +++ b/src/main/resources/io/jenkins/plugins/openmfa/MFAGlobalConfiguration/config_es.properties @@ -2,3 +2,10 @@ config.section.title=Configuración Global de OpenMFA config.requireMFA.title=Requerir MFA para todos los usuarios config.issuer.title=Nombre del Emisor config.mfaManagement.title=Panel de Gestión de MFA + +# Exemption configuration messages +config.exemptions.title=Exenciones de MFA +config.exemptUsers.title=Usuarios Exentos +config.exemptUsers.description=Un nombre de usuario por l\u00ednea. Los usuarios exentos omiten MFA completamente. +config.exemptRoles.title=Roles/Grupos Exentos +config.exemptRoles.description=Un rol por l\u00ednea. Los usuarios con estos roles omiten MFA. diff --git a/src/main/resources/io/jenkins/plugins/openmfa/MFAGlobalConfiguration/config_zh_CN.properties b/src/main/resources/io/jenkins/plugins/openmfa/MFAGlobalConfiguration/config_zh_CN.properties index d0462f8..4067d12 100644 --- a/src/main/resources/io/jenkins/plugins/openmfa/MFAGlobalConfiguration/config_zh_CN.properties +++ b/src/main/resources/io/jenkins/plugins/openmfa/MFAGlobalConfiguration/config_zh_CN.properties @@ -2,3 +2,10 @@ config.section.title=OpenMFA 配置 config.requireMFA.title=要求所有用户启用 MFA config.issuer.title=发行者名称 config.mfaManagement.title=MFA 用户管理 + +# Exemption configuration messages +config.exemptions.title=MFA 豁免 +config.exemptUsers.title=豁免用户 +config.exemptUsers.description=每行一个用户名。豁免用户可完全绕过 MFA。 +config.exemptRoles.title=豁免角色/组 +config.exemptRoles.description=每行一个角色。具有这些角色的用户可绕过 MFA。 diff --git a/src/main/resources/io/jenkins/plugins/openmfa/MFAGlobalConfiguration/help-exemptRoles.jelly b/src/main/resources/io/jenkins/plugins/openmfa/MFAGlobalConfiguration/help-exemptRoles.jelly new file mode 100644 index 0000000..63d9f3a --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/openmfa/MFAGlobalConfiguration/help-exemptRoles.jelly @@ -0,0 +1,6 @@ + +
+ List of role/group names exempt from MFA requirements. Enter one role per + line. Users with any of these roles bypass MFA verification. Use for CI/CD + service accounts or automated agents. +
diff --git a/src/main/resources/io/jenkins/plugins/openmfa/MFAGlobalConfiguration/help-exemptUsers.jelly b/src/main/resources/io/jenkins/plugins/openmfa/MFAGlobalConfiguration/help-exemptUsers.jelly new file mode 100644 index 0000000..c2cf936 --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/openmfa/MFAGlobalConfiguration/help-exemptUsers.jelly @@ -0,0 +1,6 @@ + +
+ List of usernames exempt from MFA requirements. Enter one username per line. + Exempted users can access Jenkins without completing MFA verification. Use for + service accounts or emergency access accounts. +
diff --git a/src/main/resources/io/jenkins/plugins/openmfa/Messages.properties b/src/main/resources/io/jenkins/plugins/openmfa/Messages.properties index 2ec718f..7301b65 100644 --- a/src/main/resources/io/jenkins/plugins/openmfa/Messages.properties +++ b/src/main/resources/io/jenkins/plugins/openmfa/Messages.properties @@ -9,4 +9,4 @@ DisplayNames.MULTI_FACTOR_AUTHENTICATION=Multi-Factor Authentication (MFA) DisplayNames.OPENMFA_GLOBAL_CONFIGURATION=OpenMFA # Management link description -MFAManagementLink.description=View and manage MFA status for all users +MFAManagementLink.DESCRIPTION=Manage user MFA enrollment and reset MFA for users diff --git a/src/main/resources/io/jenkins/plugins/openmfa/Messages_es.properties b/src/main/resources/io/jenkins/plugins/openmfa/Messages_es.properties index e2f217e..afb123c 100644 --- a/src/main/resources/io/jenkins/plugins/openmfa/Messages_es.properties +++ b/src/main/resources/io/jenkins/plugins/openmfa/Messages_es.properties @@ -9,4 +9,4 @@ DisplayNames.MULTI_FACTOR_AUTHENTICATION=Autenticaci\u00f3n Multifactor (MFA) DisplayNames.OPENMFA_GLOBAL_CONFIGURATION=OpenMFA # Management link description -MFAManagementLink.description=Ver y gestionar el estado de MFA para todos los usuarios +MFAManagementLink.DESCRIPTION=Ver y gestionar el estado de MFA para todos los usuarios diff --git a/src/main/resources/io/jenkins/plugins/openmfa/Messages_zh_CN.properties b/src/main/resources/io/jenkins/plugins/openmfa/Messages_zh_CN.properties index 328b0dd..35f2c3c 100644 --- a/src/main/resources/io/jenkins/plugins/openmfa/Messages_zh_CN.properties +++ b/src/main/resources/io/jenkins/plugins/openmfa/Messages_zh_CN.properties @@ -9,4 +9,4 @@ DisplayNames.MULTI_FACTOR_AUTHENTICATION=多因素认证 (MFA) DisplayNames.OPENMFA_GLOBAL_CONFIGURATION=OpenMFA 全局配置 # Management link description -MFAManagementLink.description=查看并管理所有用户的 MFA 状态 +MFAManagementLink.DESCRIPTION=查看并管理所有用户的 MFA 状态 diff --git a/src/test/java/io/jenkins/plugins/openmfa/MFAFilterWebTest.java b/src/test/java/io/jenkins/plugins/openmfa/MFAFilterWebTest.java index b7eeee6..d150987 100644 --- a/src/test/java/io/jenkins/plugins/openmfa/MFAFilterWebTest.java +++ b/src/test/java/io/jenkins/plugins/openmfa/MFAFilterWebTest.java @@ -66,6 +66,54 @@ void testCompleteMFAEnrollmentFlow(JenkinsRule j) throws Exception { } } + /** + * Test that exempt users can access resources without MFA when requireMFA is + * enabled. + */ + @Test + void testExemptUserBypassesMFA(JenkinsRule j) throws Exception { + j.jenkins.setSecurityRealm(j.createDummySecurityRealm()); + + // Enable requireMFA + MFAGlobalConfiguration config = MFAGlobalConfiguration.get(); + config.setRequireMFA(true); + config.setExemptUsers("exemptuser"); + + // Create exempt user + @SuppressWarnings("unused") + User exemptUser = User.getById("exemptuser", true); + + try (var wc = j.createWebClient()) { + wc.setThrowExceptionOnFailingStatusCode(false); + wc.login("exemptuser"); + + // Exempt user should be able to access root without MFA + HtmlPage rootPage = wc.goTo(""); + assertNotNull(rootPage, "Exempt user should access root without MFA"); + } + } + + /** + * Test that exemption by username is case-insensitive. + */ + @Test + void testExemptUserCaseInsensitive(JenkinsRule j) throws Exception { + j.jenkins.setSecurityRealm(j.createDummySecurityRealm()); + + // Set exemption with uppercase + MFAGlobalConfiguration config = MFAGlobalConfiguration.get(); + config.setRequireMFA(true); + config.setExemptUsers("EXEMPTUSER"); + + try (var wc = j.createWebClient()) { + wc.setThrowExceptionOnFailingStatusCode(false); + wc.login("exemptuser"); // Login with lowercase + + HtmlPage rootPage = wc.goTo(""); + assertNotNull(rootPage, "Exemption should be case-insensitive"); + } + } + /** * Test login form submission with valid credentials. */ @@ -130,6 +178,31 @@ void testMFASetupPageAccessible(JenkinsRule j) throws Exception { } } + /** + * Test that non-exempt users are challenged for MFA when requireMFA is enabled. + */ + @Test + void testNonExemptUserChallengedForMFA(JenkinsRule j) throws Exception { + j.jenkins.setSecurityRealm(j.createDummySecurityRealm()); + + // Enable requireMFA with no exemptions + MFAGlobalConfiguration config = MFAGlobalConfiguration.get(); + config.setRequireMFA(true); + config.setExemptUsers(""); + + try (var wc = j.createWebClient()) { + wc.setThrowExceptionOnFailingStatusCode(false); + wc.login("regularuser"); + + // Non-exempt user should be redirected to MFA setup/challenge + HtmlPage page = wc.goTo(""); + + // Should be redirected to MFA-related page (not the requested page) + // The exact behavior depends on MFA enrollment state + assertNotNull(page); + } + } + /** * Test session behavior after MFA verification. */ diff --git a/src/test/java/io/jenkins/plugins/openmfa/MFAGlobalConfigurationTest.java b/src/test/java/io/jenkins/plugins/openmfa/MFAGlobalConfigurationTest.java new file mode 100644 index 0000000..c3682e2 --- /dev/null +++ b/src/test/java/io/jenkins/plugins/openmfa/MFAGlobalConfigurationTest.java @@ -0,0 +1,34 @@ +package io.jenkins.plugins.openmfa; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.junit.jupiter.WithJenkins; + +@WithJenkins +class MFAGlobalConfigurationTest { + + @Test + void testExemptionConfigurationDefaults(JenkinsRule j) throws Exception { + MFAGlobalConfiguration config = MFAGlobalConfiguration.get(); + + assertTrue(config.getExemptUsers().isEmpty()); + assertTrue(config.getExemptRoles().isEmpty()); + } + + @Test + void testExemptionConfigurationPersistence(JenkinsRule j) throws Exception { + MFAGlobalConfiguration config = MFAGlobalConfiguration.get(); + + // Configure exemptions + config.setExemptUsers("svc-account1\nsvc-account2"); + config.setExemptRoles("ci-agents\nservice-accounts"); + config.save(); + + // Reload and verify + MFAGlobalConfiguration loaded = MFAGlobalConfiguration.get(); + assertEquals("svc-account1\nsvc-account2", loaded.getExemptUsers()); + assertEquals("ci-agents\nservice-accounts", loaded.getExemptRoles()); + } +} diff --git a/src/test/java/io/jenkins/plugins/openmfa/service/ExemptionServiceTest.java b/src/test/java/io/jenkins/plugins/openmfa/service/ExemptionServiceTest.java new file mode 100644 index 0000000..68e906b --- /dev/null +++ b/src/test/java/io/jenkins/plugins/openmfa/service/ExemptionServiceTest.java @@ -0,0 +1,76 @@ +package io.jenkins.plugins.openmfa.service; + +import static org.junit.jupiter.api.Assertions.*; + +import hudson.model.User; +import io.jenkins.plugins.openmfa.MFAGlobalConfiguration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.junit.jupiter.WithJenkins; + +@WithJenkins +class ExemptionServiceTest { + + @BeforeEach + void setUp() { + // Reset fallback instance to ensure clean state for each test + MFAGlobalConfiguration.resetFallbackInstance(); + } + + @AfterEach + void tearDown() { + // Clean up after each test + MFAGlobalConfiguration.resetFallbackInstance(); + } + + @Test + void testUserExemptByRole(JenkinsRule j) throws Exception { + j.jenkins.setSecurityRealm(j.createDummySecurityRealm()); + MFAGlobalConfiguration config = MFAGlobalConfiguration.get(); + config.setExemptRoles("admin\nci-agents"); + ExemptionService exemptionService = new ExemptionService(); + User user = User.getById("adminuser", true); + // User has no roles assigned yet in test environment + assertFalse(exemptionService.isExempt(user)); + } + + @Test + void testUserExemptByUsername(JenkinsRule j) throws Exception { + j.jenkins.setSecurityRealm(j.createDummySecurityRealm()); + MFAGlobalConfiguration config = MFAGlobalConfiguration.get(); + config.setExemptUsers("testuser\nexemptuser"); + ExemptionService exemptionService = new ExemptionService(); + User user = User.getById("testuser", true); + assertTrue(exemptionService.isExempt(user)); + } + + @Test + void testUserExemptByUsernameCaseInsensitive(JenkinsRule j) throws Exception { + j.jenkins.setSecurityRealm(j.createDummySecurityRealm()); + MFAGlobalConfiguration config = MFAGlobalConfiguration.get(); + config.setExemptUsers("TESTUSER"); + ExemptionService exemptionService = new ExemptionService(); + User user = User.getById("testuser", true); + assertTrue(exemptionService.isExempt(user)); + } + + @Test + void testUserNotExemptByUsername(JenkinsRule j) throws Exception { + j.jenkins.setSecurityRealm(j.createDummySecurityRealm()); + MFAGlobalConfiguration config = MFAGlobalConfiguration.get(); + config.setExemptUsers("otheruser"); + ExemptionService exemptionService = new ExemptionService(); + User user = User.getById("testuser", true); + assertFalse(exemptionService.isExempt(user)); + } + + @Test + void testUserNotExemptWhenListsEmpty(JenkinsRule j) throws Exception { + j.jenkins.setSecurityRealm(j.createDummySecurityRealm()); + ExemptionService exemptionService = new ExemptionService(); + User user = User.getById("testuser", true); + assertFalse(exemptionService.isExempt(user)); + } +}