diff --git a/docs/ahriman.core.auth.rst b/docs/ahriman.core.auth.rst index cd2d7857..24b8001c 100644 --- a/docs/ahriman.core.auth.rst +++ b/docs/ahriman.core.auth.rst @@ -36,6 +36,14 @@ ahriman.core.auth.oauth module :no-undoc-members: :show-inheritance: +ahriman.core.auth.pam module +---------------------------- + +.. automodule:: ahriman.core.auth.pam + :members: + :no-undoc-members: + :show-inheritance: + Module contents --------------- diff --git a/docs/ahriman.core.rst b/docs/ahriman.core.rst index 624f394d..b33a3c54 100644 --- a/docs/ahriman.core.rst +++ b/docs/ahriman.core.rst @@ -52,6 +52,14 @@ ahriman.core.tree module :no-undoc-members: :show-inheritance: +ahriman.core.util module +------------------------ + +.. automodule:: ahriman.core.util + :members: + :no-undoc-members: + :show-inheritance: + ahriman.core.utils module ------------------------- diff --git a/docs/configuration.rst b/docs/configuration.rst index c3011e4e..16f2efbd 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -61,15 +61,17 @@ libalpm and AUR related configuration. Group name can refer to architecture, e.g Base authorization settings. ``OAuth`` provider requires ``aioauth-client`` library to be installed. -* ``target`` - specifies authorization provider, string, optional, default ``disabled``. Allowed values are ``disabled``, ``configuration``, ``oauth``. +* ``target`` - specifies authorization provider, string, optional, default ``disabled``. Allowed values are ``disabled``, ``configuration``, ``oauth``, ``pam``. * ``allow_read_only`` - allow requesting status APIs without authorization, boolean, required. * ``client_id`` - OAuth2 application client ID, string, required in case if ``oauth`` is used. * ``client_secret`` - OAuth2 application client secret key, string, required in case if ``oauth`` is used. * ``cookie_secret_key`` - secret key which will be used for cookies encryption, string, optional. It must be 32 bytes URL-safe base64-encoded and can be generated as following ``base64.urlsafe_b64encode(os.urandom(32)).decode("utf8")``. If not set, it will be generated automatically; note, however, that in this case, all sessions will be automatically invalidated during the service restart. +* ``full_access_group`` - name of the secondary group (e.g. ``wheel``) to be used as admin group in the service, string, required in case if ``pam`` is used. * ``max_age`` - parameter which controls both cookie expiration and token expiration inside the service in seconds, integer, optional, default is 7 days. * ``oauth_icon`` - OAuth2 login button icon, string, optional, default is ``google``. Must be valid `Bootstrap icon `__ name. * ``oauth_provider`` - OAuth2 provider class name as is in ``aioauth-client`` (e.g. ``GoogleClient``, ``GithubClient`` etc), string, required in case if ``oauth`` is used. * ``oauth_scopes`` - scopes list for OAuth2 provider, which will allow retrieving user email (which is used for checking user permissions), e.g. ``https://www.googleapis.com/auth/userinfo.email`` for ``GoogleClient`` or ``user:email`` for ``GithubClient``, space separated list of strings, required in case if ``oauth`` is used. +* ``permit_root_login`` - allow login as root user, boolean, optional, default ``no``. * ``salt`` - additional password hash salt, string, optional. Authorized users are stored inside internal database, if any of external providers (e.g. ``oauth``) are used, the password field for non-service users must be empty. diff --git a/docs/faq.rst b/docs/faq.rst index dcb9b268..d4247b6a 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -1348,6 +1348,19 @@ How to enable basic authorization #. Restart web service ``systemctl restart ahriman-web``. +Using PAM authentication +"""""""""""""""""""""""" + +There is also ability to allow system users to log in. To do so, the following configuration have to be set: + +.. code-block:: ini + + [auth] + target = pam + full_access_group = wheel + +With this setup, every user (except root) will be able to log in by using system password. If user belongs to the ``wheel`` group, the full access will be automatically granted. It is also possible to manually add, block user or change user rights via usual user management process. + How to enable OAuth authorization ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/package/share/ahriman/settings/ahriman.ini b/package/share/ahriman/settings/ahriman.ini index 5b17b0d7..45a81abe 100644 --- a/package/share/ahriman/settings/ahriman.ini +++ b/package/share/ahriman/settings/ahriman.ini @@ -34,6 +34,8 @@ allow_read_only = yes ; Cookie secret key to be used for cookies encryption. Must be valid 32 bytes URL-safe base64-encoded string. ; If not set, it will be generated automatically. ;cookie_secret_key = +; Name of the secondary group to be used as admin group in the service. +;full_access_group = wheel ; Authentication cookie expiration in seconds. ;max_age = 604800 ; OAuth2 provider icon for the web interface. @@ -42,6 +44,8 @@ allow_read_only = yes ;oauth_provider = GoogleClient ; Scopes list for OAuth2 provider. Required if oauth is used. ;oauth_scopes = https://www.googleapis.com/auth/userinfo.email +; Allow login as root user (only if PAM is used). +;permit_root_login = no ; Optional password salt. ;salt = diff --git a/recipes/README.md b/recipes/README.md index 11ecd538..bcd817c9 100644 --- a/recipes/README.md +++ b/recipes/README.md @@ -12,6 +12,7 @@ Collection of the examples of docker compose configuration files, which covers s * [Index](index): repository with index page generator enabled. * [Multi repo](multirepo): run web service with two separated repositories. * [OAuth](oauth): web service with OAuth (GitHub provider) authentication enabled. +* [PAM](pam): web service with PAM authentication enabled. * [Pull](pull): normal service, but in addition with pulling packages from another source (e.g. GitHub repository). * [Sign](sign): create repository with database signing. * [Web](web): simple web service with authentication enabled. diff --git a/recipes/pam/README.md b/recipes/pam/README.md new file mode 100644 index 00000000..fb8bbc3b --- /dev/null +++ b/recipes/pam/README.md @@ -0,0 +1,6 @@ +# PAM + +1. Create system user `demo` with password from `AHRIMAN_PASSWORD` environment variable and group `wheel`. +2. Setup repository named `ahriman-demo` with architecture `x86_64`. +3. Start web server at port `8080`. +4. Repository is available at `http://localhost:8080/repo`. diff --git a/recipes/pam/compose.yml b/recipes/pam/compose.yml new file mode 100644 index 00000000..5f61d980 --- /dev/null +++ b/recipes/pam/compose.yml @@ -0,0 +1,63 @@ +services: + backend: + image: arcan1s/ahriman:edge + privileged: true + + environment: + AHRIMAN_DEBUG: yes + AHRIMAN_OUTPUT: console + AHRIMAN_PASSWORD: ${AHRIMAN_PASSWORD} + AHRIMAN_PORT: 8080 + AHRIMAN_PRESETUP_COMMAND: useradd -d / -G wheel -M demo; (cat /run/secrets/password; echo; cat /run/secrets/password) | passwd demo + AHRIMAN_REPOSITORY: ahriman-demo + AHRIMAN_UNIX_SOCKET: /var/lib/ahriman/ahriman/ahriman.sock + + configs: + - source: service + target: /etc/ahriman.ini.d/99-settings.ini + secrets: + - password + + volumes: + - type: volume + source: repository + target: /var/lib/ahriman + volume: + nocopy: true + + healthcheck: + test: curl --fail --silent --output /dev/null http://backend:8080/api/v1/info + interval: 10s + start_period: 30s + + command: web + + frontend: + image: nginx + ports: + - 8080:80 + + configs: + - source: nginx + target: /etc/nginx/conf.d/default.conf + + volumes: + - type: volume + source: repository + target: /srv + read_only: true + volume: + nocopy: true + +configs: + nginx: + file: nginx.conf + service: + file: service.ini + +secrets: + password: + environment: AHRIMAN_PASSWORD + +volumes: + repository: diff --git a/recipes/pam/nginx.conf b/recipes/pam/nginx.conf new file mode 100644 index 00000000..fdd7195e --- /dev/null +++ b/recipes/pam/nginx.conf @@ -0,0 +1,18 @@ +server { + listen 80; + + location /repo { + rewrite ^/repo/(.*) /$1 break; + autoindex on; + root /srv/ahriman/repository; + } + + location / { + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarder-Proto $scheme; + + proxy_pass http://backend:8080; + } +} diff --git a/recipes/pam/service.ini b/recipes/pam/service.ini new file mode 100644 index 00000000..9c36aaec --- /dev/null +++ b/recipes/pam/service.ini @@ -0,0 +1,3 @@ +[auth] +target = pam +full_access_group = wheel diff --git a/src/ahriman/core/auth/auth.py b/src/ahriman/core/auth/auth.py index 1dd86625..8bcfa018 100644 --- a/src/ahriman/core/auth/auth.py +++ b/src/ahriman/core/auth/auth.py @@ -81,15 +81,18 @@ class Auth(LazyLogging): case AuthSettings.OAuth: from ahriman.core.auth.oauth import OAuth return OAuth(configuration, database) + case AuthSettings.PAM: + from ahriman.core.auth.pam import PAM + return PAM(configuration, database) case _: return Auth(configuration) - async def check_credentials(self, username: str | None, password: str | None) -> bool: + async def check_credentials(self, username: str, password: str | None) -> bool: """ validate user password Args: - username(str | None): username + username(str): username password(str | None): entered password Returns: @@ -98,12 +101,12 @@ class Auth(LazyLogging): del username, password return True - async def known_username(self, username: str | None) -> bool: + async def known_username(self, username: str) -> bool: """ check if user is known Args: - username(str | None): username + username(str): username Returns: bool: True in case if user is known and can be authorized and False otherwise diff --git a/src/ahriman/core/auth/mapping.py b/src/ahriman/core/auth/mapping.py index 8cb4ec1e..e3b6f8cb 100644 --- a/src/ahriman/core/auth/mapping.py +++ b/src/ahriman/core/auth/mapping.py @@ -48,18 +48,18 @@ class Mapping(Auth): self.database = database self.salt = configuration.get("auth", "salt", fallback="") - async def check_credentials(self, username: str | None, password: str | None) -> bool: + async def check_credentials(self, username: str, password: str | None) -> bool: """ validate user password Args: - username(str | None): username + username(str): username password(str | None): entered password Returns: bool: True in case if password matches, False otherwise """ - if username is None or password is None: + if password is None: return False # invalid data supplied user = self.get_user(username) return user is not None and user.check_credentials(password, self.salt) @@ -76,12 +76,12 @@ class Mapping(Auth): """ return self.database.user_get(username) - async def known_username(self, username: str | None) -> bool: + async def known_username(self, username: str) -> bool: """ check if user is known Args: - username(str | None): username + username(str): username Returns: bool: True in case if user is known and can be authorized and False otherwise diff --git a/src/ahriman/core/auth/pam.py b/src/ahriman/core/auth/pam.py new file mode 100644 index 00000000..4bf4c6ec --- /dev/null +++ b/src/ahriman/core/auth/pam.py @@ -0,0 +1,131 @@ +# +# Copyright (c) 2021-2024 ahriman team. +# +# This file is part of ahriman +# (see https://github.com/arcan1s/ahriman). +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +from grp import getgrnam +from pwd import getpwnam + +from ahriman.core.auth.mapping import Mapping +from ahriman.core.configuration import Configuration +from ahriman.core.database import SQLite +from ahriman.core.exceptions import CalledProcessError +from ahriman.core.utils import check_output +from ahriman.models.auth_settings import AuthSettings +from ahriman.models.user_access import UserAccess + + +class PAM(Mapping): + """ + User authorization implementation by using default PAM + + Attributes: + full_access_group(str): group name users of which have full access + permit_root_login(bool): permit login as root + """ + + def __init__(self, configuration: Configuration, database: SQLite, + provider: AuthSettings = AuthSettings.PAM) -> None: + """ + default constructor + + Args: + configuration(Configuration): configuration instance + database(SQLite): database instance + provider(AuthSettings, optional): authorization type definition (Default value = AuthSettings.PAM) + """ + Mapping.__init__(self, configuration, database, provider) + self.full_access_group = configuration.get("auth", "full_access_group") + self.permit_root_login = configuration.getboolean("auth", "permit_root_login", fallback=False) + + @staticmethod + def group_members(group_name: str) -> list[str]: + """ + extract current group members + + Args: + group_name(str): group name + + Returns: + list[str]: list of users which belong to the specified group. In case if group wasn't found, the empty list + will be returned + """ + try: + group = getgrnam(group_name) + except KeyError: + return [] + return group.gr_mem + + async def check_credentials(self, username: str, password: str | None) -> bool: + """ + validate user password + + Args: + username(str): username + password(str | None): entered password + + Returns: + bool: True in case if password matches, False otherwise + """ + if password is None: + return False # invalid data supplied + if not self.permit_root_login and username == "root": + return False # login as root is not allowed + # the reason why do we call su here is that python-pam actually read shadow file + # and hence requires root privileges + try: + check_output("su", "--command", "true", "-", username, input_data=password) + return True + except CalledProcessError: + return await Mapping.check_credentials(self, username, password) + + async def known_username(self, username: str) -> bool: + """ + check if user is known + + Args: + username(str): username + + Returns: + bool: True in case if user is known and can be authorized and False otherwise + """ + try: + _ = getpwnam(username) + return True + except KeyError: + return await Mapping.known_username(self, username) + + async def verify_access(self, username: str, required: UserAccess, context: str | None) -> bool: + """ + validate if user has access to requested resource + + Args: + username(str): username + required(UserAccess): required access level + context(str | None): URI request path + + Returns: + bool: True in case if user is allowed to do this request and False otherwise + """ + # this method is basically inverted, first we check overrides in database and then fallback to the PAM logic + if (user := self.get_user(username)) is not None: + return user.verify_access(required) + # if username is in admin group, then we treat it as full access + if username in self.group_members(self.full_access_group): + return UserAccess.Full.permits(required) + # fallback to read-only accounts + return UserAccess.Read.permits(required) diff --git a/src/ahriman/core/configuration/schema.py b/src/ahriman/core/configuration/schema.py index f890ce73..7fd0b7ff 100644 --- a/src/ahriman/core/configuration/schema.py +++ b/src/ahriman/core/configuration/schema.py @@ -115,6 +115,7 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = { "oauth_provider", "oauth_scopes", ]}, + {"allowed": ["pam"], "dependencies": ["full_access_group"]}, ], }, "allow_read_only": { @@ -135,6 +136,10 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = { "minlength": 32, "maxlength": 64, # we cannot verify maxlength, because base64 representation might be longer than bytes }, + "full_access_group": { + "type": "string", + "empty": False, + }, "max_age": { "type": "integer", "coerce": "integer", @@ -152,6 +157,10 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = { "type": "string", "empty": False, }, + "permit_root_login": { + "type": "boolean", + "coerce": "boolean", + }, "salt": { "type": "string", }, diff --git a/src/ahriman/models/auth_settings.py b/src/ahriman/models/auth_settings.py index e761a3e7..094bd0e5 100644 --- a/src/ahriman/models/auth_settings.py +++ b/src/ahriman/models/auth_settings.py @@ -30,11 +30,13 @@ class AuthSettings(StrEnum): Disabled(AuthSettings): (class attribute) authorization is disabled Configuration(AuthSettings): (class attribute) configuration based authorization OAuth(AuthSettings): (class attribute) OAuth based provider + PAM(AuthSettings): (class attribute) PAM based provider """ Disabled = "disabled" Configuration = "configuration" OAuth = "oauth2" + PAM = "pam" @property def is_enabled(self) -> bool: @@ -62,5 +64,7 @@ class AuthSettings(StrEnum): return AuthSettings.Configuration case "oauth" | "oauth2": return AuthSettings.OAuth + case "pam": + return AuthSettings.PAM case _: return AuthSettings.Disabled diff --git a/tests/ahriman/core/auth/conftest.py b/tests/ahriman/core/auth/conftest.py index 57d1dc35..46e2bdac 100644 --- a/tests/ahriman/core/auth/conftest.py +++ b/tests/ahriman/core/auth/conftest.py @@ -2,6 +2,7 @@ import pytest from ahriman.core.auth.mapping import Mapping from ahriman.core.auth.oauth import OAuth +from ahriman.core.auth.pam import PAM from ahriman.core.configuration import Configuration from ahriman.core.database import SQLite @@ -35,3 +36,19 @@ def oauth(configuration: Configuration, database: SQLite) -> OAuth: """ configuration.set("web", "address", "https://example.com") return OAuth(configuration, database) + + +@pytest.fixture +def pam(configuration: Configuration, database: SQLite) -> PAM: + """ + PAM provider fixture + + Args: + configuration(Configuration): configuration fixture + database(SQLite): database fixture + + Returns: + PAM: PAM service instance + """ + configuration.set_option("auth", "full_access_group", "wheel") + return PAM(configuration, database) diff --git a/tests/ahriman/core/auth/test_auth.py b/tests/ahriman/core/auth/test_auth.py index 600d521d..f0a73796 100644 --- a/tests/ahriman/core/auth/test_auth.py +++ b/tests/ahriman/core/auth/test_auth.py @@ -1,6 +1,7 @@ from ahriman.core.auth import Auth from ahriman.core.auth.mapping import Mapping from ahriman.core.auth.oauth import OAuth +from ahriman.core.auth.pam import PAM from ahriman.core.configuration import Configuration from ahriman.core.database import SQLite from ahriman.models.user import User @@ -51,14 +52,22 @@ def test_load_oauth(configuration: Configuration, database: SQLite) -> None: assert isinstance(auth, OAuth) +def test_load_pam(configuration: Configuration, database: SQLite) -> None: + """ + must load pam validator if option set + """ + configuration.set_option("auth", "target", "pam") + configuration.set_option("auth", "full_access_group", "wheel") + auth = Auth.load(configuration, database) + assert isinstance(auth, PAM) + + async def test_check_credentials(auth: Auth, user: User) -> None: """ must pass any credentials """ assert await auth.check_credentials(user.username, user.password) - assert await auth.check_credentials(None, "") assert await auth.check_credentials("", None) - assert await auth.check_credentials(None, None) async def test_known_username(auth: Auth, user: User) -> None: diff --git a/tests/ahriman/core/auth/test_mapping.py b/tests/ahriman/core/auth/test_mapping.py index 094f48aa..194f4fbd 100644 --- a/tests/ahriman/core/auth/test_mapping.py +++ b/tests/ahriman/core/auth/test_mapping.py @@ -21,9 +21,7 @@ async def test_check_credentials_empty(mapping: Mapping) -> None: """ must reject on empty credentials """ - assert not await mapping.check_credentials(None, "") assert not await mapping.check_credentials("", None) - assert not await mapping.check_credentials(None, None) async def test_check_credentials_unknown(mapping: Mapping, user: User) -> None: @@ -66,9 +64,8 @@ async def test_known_username(mapping: Mapping, user: User, mocker: MockerFixtur async def test_known_username_unknown(mapping: Mapping, user: User, mocker: MockerFixture) -> None: """ - must not allow only known users + must not allow unknown users """ - assert not await mapping.known_username(None) mocker.patch("ahriman.core.database.SQLite.user_get", return_value=None) assert not await mapping.known_username(user.password) diff --git a/tests/ahriman/core/auth/test_pam.py b/tests/ahriman/core/auth/test_pam.py new file mode 100644 index 00000000..379c2fba --- /dev/null +++ b/tests/ahriman/core/auth/test_pam.py @@ -0,0 +1,118 @@ +from pytest_mock import MockerFixture + +from ahriman.core.auth.pam import PAM +from ahriman.core.exceptions import CalledProcessError +from ahriman.models.user import User +from ahriman.models.user_access import UserAccess + + +def test_group_members() -> None: + """ + must return current group members + """ + assert "root" in PAM.group_members("root") + + +def test_group_members_unknown() -> None: + """ + must return empty list for unknown group + """ + assert not PAM.group_members("somerandomgroupname") + + +async def test_check_credentials(pam: PAM, user: User, mocker: MockerFixture) -> None: + """ + must correctly check user credentials via PAM + """ + authenticate_mock = mocker.patch("ahriman.core.auth.pam.check_output") + mapping_mock = mocker.patch("ahriman.core.auth.mapping.Mapping.check_credentials") + + assert await pam.check_credentials(user.username, user.password) + authenticate_mock.assert_called_once_with("su", "--command", "true", "-", user.username, + input_data=user.password) + mapping_mock.assert_not_called() + + +async def test_check_credentials_empty(pam: PAM) -> None: + """ + must reject on empty credentials + """ + assert not await pam.check_credentials("", None) + + +async def test_check_credentials_root(pam: PAM, user: User, mocker: MockerFixture) -> None: + """ + must reject on root logon attempt + """ + mocker.patch("ahriman.core.auth.pam.check_output") + assert not await pam.check_credentials("root", user.password) + + pam.permit_root_login = True + assert await pam.check_credentials("root", user.password) + + +async def test_check_credentials_mapping(pam: PAM, user: User, mocker: MockerFixture) -> None: + """ + must correctly check user credentials via database if PAM rejected + """ + mocker.patch("ahriman.core.auth.pam.check_output", + side_effect=CalledProcessError(1, ["command"], "error")) + mapping_mock = mocker.patch("ahriman.core.auth.mapping.Mapping.check_credentials") + + await pam.check_credentials(user.username, user.password) + mapping_mock.assert_called_once_with(pam, user.username, user.password) + + +async def test_known_username(pam: PAM, user: User, mocker: MockerFixture) -> None: + """ + must check if user exists in system + """ + getpwnam_mock = mocker.patch("ahriman.core.auth.pam.getpwnam") + mapping_mock = mocker.patch("ahriman.core.auth.mapping.Mapping.known_username") + + assert await pam.known_username(user.username) + getpwnam_mock.assert_called_once_with(user.username) + mapping_mock.assert_not_called() + + +async def test_known_username_mapping(pam: PAM, user: User, mocker: MockerFixture) -> None: + """ + must fallback to username checking to database if no user found in system + """ + mocker.patch("ahriman.core.auth.pam.getpwnam", side_effect=KeyError) + mapping_mock = mocker.patch("ahriman.core.auth.mapping.Mapping.known_username") + + await pam.known_username(user.username) + mapping_mock.assert_called_once_with(pam, user.username) + + +async def test_verify_access(pam: PAM, user: User, mocker: MockerFixture) -> None: + """ + must verify user access via PAM groups + """ + mocker.patch("ahriman.core.auth.pam.PAM.get_user", return_value=None) + mocker.patch("ahriman.core.auth.pam.PAM.group_members", return_value=[user.username]) + assert await pam.verify_access(user.username, UserAccess.Full, None) + + +async def test_verify_access_readonly(pam: PAM, user: User, mocker: MockerFixture) -> None: + """ + must set user access to read only if it doesn't belong to the admin group + """ + mocker.patch("ahriman.core.auth.pam.PAM.get_user", return_value=None) + mocker.patch("ahriman.core.auth.pam.PAM.group_members", return_value=[]) + + assert not await pam.verify_access(user.username, UserAccess.Full, None) + assert not await pam.verify_access(user.username, UserAccess.Reporter, None) + assert await pam.verify_access(user.username, UserAccess.Read, None) + + +async def test_verify_access_override(pam: PAM, user: User, mocker: MockerFixture) -> None: + """ + must verify user access via database if there is override + """ + mocker.patch("ahriman.core.auth.pam.PAM.get_user", return_value=user) + group_mock = mocker.patch("ahriman.core.auth.pam.PAM.group_members") + + assert await pam.verify_access(user.username, user.access, None) + group_mock.assert_not_called() diff --git a/tests/ahriman/models/test_auth_settings.py b/tests/ahriman/models/test_auth_settings.py index 9d46713c..640fa8b1 100644 --- a/tests/ahriman/models/test_auth_settings.py +++ b/tests/ahriman/models/test_auth_settings.py @@ -26,6 +26,9 @@ def test_from_option_valid() -> None: assert AuthSettings.from_option("mapping") == AuthSettings.Configuration assert AuthSettings.from_option("MAPPing") == AuthSettings.Configuration + assert AuthSettings.from_option("pam") == AuthSettings.PAM + assert AuthSettings.from_option("PAM") == AuthSettings.PAM + def test_is_enabled() -> None: """