From 486a1264d57d9610a7156ca83ee9113fbc3c0269 Mon Sep 17 00:00:00 2001 From: TVo Date: Mon, 30 Sep 2024 07:23:27 -0600 Subject: [PATCH 1/5] Removed docs associated with OIDC auth (#15557) Removed docs associated with OIDC auth. --- .../configure_awx_authentication.rst | 2 -- docs/docsite/rst/administration/ent_auth.rst | 36 ------------------- 2 files changed, 38 deletions(-) diff --git a/docs/docsite/rst/administration/configure_awx_authentication.rst b/docs/docsite/rst/administration/configure_awx_authentication.rst index fb77bb0c1fb2..dd7d188be8b9 100644 --- a/docs/docsite/rst/administration/configure_awx_authentication.rst +++ b/docs/docsite/rst/administration/configure_awx_authentication.rst @@ -11,8 +11,6 @@ Through the AWX user interface, you can set up a simplified login through variou - :ref:`ag_auth_radius` - :ref:`ag_auth_saml` - :ref:`ag_auth_tacacs` -- :ref:`ag_auth_oidc` - Different authentication types require you to enter different information. Be sure to include all the information as required. diff --git a/docs/docsite/rst/administration/ent_auth.rst b/docs/docsite/rst/administration/ent_auth.rst index 17c95edcdc79..dbf897378a4f 100644 --- a/docs/docsite/rst/administration/ent_auth.rst +++ b/docs/docsite/rst/administration/ent_auth.rst @@ -552,39 +552,3 @@ Terminal Access Controller Access-Control System Plus (TACACS+) is a protocol th 4. Click **Save** when done. - -.. _ag_auth_oidc: - -Generic OIDC settings ----------------------- -Similar to SAML, OpenID Connect (OIDC) is uses the OAuth 2.0 framework. It allows third-party applications to verify the identity and obtain basic end-user information. The main difference between OIDC and SAML is that SAML has a service provider (SP)-to-IdP trust relationship, whereas OIDC establishes the trust with the channel (HTTPS) that is used to obtain the security token. To obtain the credentials needed to setup OIDC with AWX, refer to the documentation from the identity provider (IdP) of your choice that has OIDC support. - -To configure OIDC in AWX: - -1. Click **Settings** from the left navigation bar. - -2. On the left side of the Settings window, click **Generic OIDC settings** from the list of Authentication options. - -3. Click **Edit** and enter information in the following fields: - -- **OIDC Key**: Client ID from your 3rd-party IdP. -- **OIDC Secret**: Client Secret from your IdP. -- **OIDC Provider URL**: URL for your OIDC provider. -- **Verify OIDC Provider Certificate**: Use the toggle to enable/disable the OIDC provider SSL certificate verification. - -The example below shows specific values associated to GitHub as the generic IdP: - - .. image:: ../common/images/configure-awx-auth-oidc.png - :alt: OpenID Connect (OIDC) configuration details in AWX settings. - -4. Click **Save** when done. - - -.. note:: - - There is currently no support for team and organization mappings for OIDC at this time. The OIDC adapter does authentication only and not authorization. In other words, it is only capable of authenticating whether this user is who they say they are, not authorizing what this user is allowed to do. Configuring generic OIDC creates the UserID appended with an ID/key to differentiate the same user ID originating from two different sources and therefore, considered different users. So one will get an ID of just the user name and the second will be the ``username-``. - -5. To verify that the authentication was configured correctly, logout of AWX and the login screen will now display the OIDC logo to indicate it as a alternate method of logging into AWX. - - .. image:: ../common/images/configure-awx-auth-oidc-logo.png - :alt: AWX login screen displaying the OpenID Connect (OIDC) logo for authentication. From 48e3afbb00f52105d2b1bd00042ee7e55d057c8a Mon Sep 17 00:00:00 2001 From: Hao Liu <44379968+TheRealHaoLiu@users.noreply.github.com> Date: Mon, 30 Sep 2024 09:50:04 -0400 Subject: [PATCH 2/5] Filter out ANSIBLE_BASE_ from job env var (#15558) --- awx/main/tasks/jobs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/main/tasks/jobs.py b/awx/main/tasks/jobs.py index 0a9e7f59755b..c6cfc6a18061 100644 --- a/awx/main/tasks/jobs.py +++ b/awx/main/tasks/jobs.py @@ -299,7 +299,7 @@ def build_env(self, instance, private_data_dir, private_data_files=None): env = {} # Add ANSIBLE_* settings to the subprocess environment. for attr in dir(settings): - if attr == attr.upper() and attr.startswith('ANSIBLE_'): + if attr == attr.upper() and attr.startswith('ANSIBLE_') and not attr.startswith('ANSIBLE_BASE_'): env[attr] = str(getattr(settings, attr)) # Also set environment variables configured in AWX_TASK_ENV setting. for key, value in settings.AWX_TASK_ENV.items(): From a1ad3206229b19bfb10e0375a871e85cea0f729d Mon Sep 17 00:00:00 2001 From: TVo Date: Mon, 30 Sep 2024 15:30:46 -0600 Subject: [PATCH 3/5] Removed docs associated with SAML auth. (#15563) --- .../configure_awx_authentication.rst | 3 +- docs/docsite/rst/administration/ent_auth.rst | 431 +----------------- .../security_best_practices.rst | 2 +- .../rst/administration/social_auth.rst | 6 +- .../rst/release_notes/known_issues.rst | 15 - 5 files changed, 4 insertions(+), 453 deletions(-) diff --git a/docs/docsite/rst/administration/configure_awx_authentication.rst b/docs/docsite/rst/administration/configure_awx_authentication.rst index dd7d188be8b9..f90576f918d5 100644 --- a/docs/docsite/rst/administration/configure_awx_authentication.rst +++ b/docs/docsite/rst/administration/configure_awx_authentication.rst @@ -1,4 +1,4 @@ -Through the AWX user interface, you can set up a simplified login through various authentication types: GitHub, Google, LDAP, RADIUS, and SAML. After you create and register your developer application with the appropriate service, you can set up authorizations for them. +Through the AWX user interface, you can set up a simplified login through various authentication types: GitHub, Google, LDAP, and RADIUS. After you create and register your developer application with the appropriate service, you can set up authorizations for them. 1. From the left navigation bar, click **Settings**. @@ -9,7 +9,6 @@ Through the AWX user interface, you can set up a simplified login through variou - :ref:`ag_auth_google_oauth2` - :ref:`LDAP settings ` - :ref:`ag_auth_radius` -- :ref:`ag_auth_saml` - :ref:`ag_auth_tacacs` Different authentication types require you to enter different information. Be sure to include all the information as required. diff --git a/docs/docsite/rst/administration/ent_auth.rst b/docs/docsite/rst/administration/ent_auth.rst index dbf897378a4f..73039f46779c 100644 --- a/docs/docsite/rst/administration/ent_auth.rst +++ b/docs/docsite/rst/administration/ent_auth.rst @@ -17,7 +17,7 @@ This section describes setting up authentication for the following enterprise sy For LDAP authentication, see :ref:`ag_auth_ldap`. -Azure, RADIUS, SAML, and TACACS+ users are categorized as 'Enterprise' users. The following rules apply to Enterprise users: +Azure, RADIUS, and TACACS+ users are categorized as 'Enterprise' users. The following rules apply to Enterprise users: - Enterprise users can only be created via the first successful login attempt from remote authentication backend. - Enterprise users cannot be created/authenticated if non-enterprise users with the same name has already been created in AWX. @@ -91,435 +91,6 @@ AWX can be configured to centrally use RADIUS as a source for authentication inf 5. Click **Save** when done. -.. _ag_auth_saml: - -SAML settings ----------------- - -.. index:: - pair: authentication; SAML Service Provider - - -SAML allows the exchange of authentication and authorization data between an Identity Provider (IdP - a system of servers that provide the Single Sign On service) and a Service Provider (in this case, AWX). AWX can be configured to talk with SAML in order to authenticate (create/login/logout) AWX users. User Team and Organization membership can be embedded in the SAML response to AWX. - -.. image:: ../common/images/configure-awx-auth-saml-topology.png - :alt: Diagram depicting SAML topology for AWX. - -The following instructions describe AWX as the service provider. - -To setup SAML authentication: - -1. Click **Settings** from the left navigation bar. - -2. On the left side of the Settings window, click **SAML settings** from the list of Authentication options. - -3. The **SAML Assertion Consume Service (ACS) URL** and **SAML Service Provider Metadata URL** fields are pre-populated and are non-editable. Contact the Identity Provider administrator and provide the information contained in these fields. - -4. Click **Edit** and set the **SAML Service Provider Entity ID** to be the same as the **Base URL of the service** field that can be found in the Miscellaneous System settings screen by clicking **Settings** from the left navigation bar. Through the API, it can be viewed in the ``/api/v2/settings/system``, under the ``TOWER_URL_BASE`` variable. The Entity ID can be set to any one of the individual AWX cluster nodes, but it is good practice to set it to the URL of the Service Provider. Ensure that the Base URL matches the FQDN of the load balancer (if used). - -.. note:: - - The Base URL is different for each node in a cluster. Commonly, a load balancer will sit in front of many AWX cluster nodes to provide a single entry point, the AWX Cluster FQDN. The SAML Service Provider must be able establish an outbound connection and route to the AWX Cluster Node or the AWX Cluster FQDN set in the SAML Service Provider Entity ID. - -In this example, the Service Provider is the AWX cluster, and therefore, the ID is set to the AWX Cluster FQDN. - -.. image:: ../common/images/configure-awx-auth-saml-spentityid.png - :alt: Configuring SAML Service Provider Entity ID in AWX. - -5. Create a server certificate for the Ansible cluster. Typically when an Ansible cluster is configured, AWX nodes will be configured to handle HTTP traffic only and the load balancer will be an SSL Termination Point. In this case, an SSL certificate is required for the load balancer, and not for the individual AWX Cluster Nodes. SSL can either be enabled or disabled per individual AWX node, but should be disabled when using an SSL terminated load balancer. It is recommended to use a non-expiring self signed certificate to avoid periodically updating certificates. This way, authentication will not fail in case someone forgets to update the certificate. - -.. note:: - - The **SAML Service Provider Public Certificate** field should contain the entire certificate, including the "-----BEGIN CERTIFICATE-----" and "-----END CERTIFICATE-----". - -If you are using a CA bundle with your certificate, include the entire bundle in this field. - -.. image:: ../common/images/configure-awx-auth-saml-cert.png - :alt: Configuring SAML Service Provider Public Certificate in AWX. - -As an example for public certs: - -:: - - -----BEGIN CERTIFICATE—— - ... cert text ... - -----END CERTIFICATE—— - -6. Create an optional private key for AWX to use as a service provider (SP) and enter it in the **SAML Service Provider Private Key** field. - -As an example for private keys: - -:: - - -----BEGIN PRIVATE KEY-- - ... key text ... - -----END PRIVATE KEY—— - - -7. Provide the IdP with some details about the AWX cluster during the SSO process in the **SAML Service Provider Organization Info** field. - -:: - - { - "en-US": { - "url": "http://www.example.com", - "displayname": "Example", - "name": "example" - } - } - -For example: - -.. image:: ../common/images/configure-awx-auth-saml-org-info.png - :alt: Configuring SAML Organization information in AWX. - -.. note:: - These fields are required in order to properly configure SAML within AWX. - -8. Provide the IdP with the technical contact information in the **SAML Service Provider Technical Contact** field. Do not remove the contents of this field. - -:: - - { - "givenName": "Some User", - "emailAddress": "suser@example.com" - } - -For example: - -.. image:: ../common/images/configure-awx-auth-saml-techcontact-info.png - :alt: Configuring SAML Technical Contact information in AWX. - -9. Provide the IdP with the support contact information in the **SAML Service Provider Support Contact** field. Do not remove the contents of this field. - -:: - - { - "givenName": "Some User", - "emailAddress": "suser@example.com" - } - -For example: - -.. image:: ../common/images/configure-awx-auth-saml-suppcontact-info.png - :alt: Configuring SAML Support Contact information in AWX. - -10. In the **SAML Enabled Identity Providers** field, provide information on how to connect to each Identity Provider listed. AWX expects the following SAML attributes in the example below: - -:: - - Username(urn:oid:0.9.2342.19200300.100.1.1) - Email(urn:oid:0.9.2342.19200300.100.1.3) - FirstName(urn:oid:2.5.4.42) - LastName(urn:oid:2.5.4.4) - -If these attributes are not known, map existing SAML attributes to lastname, firstname, email and username. - -Configure the required keys for each IDp: - - - ``attr_user_permanent_id`` - the unique identifier for the user. It can be configured to match any of the attribute sent from the IdP. Usually, it is set to ``name_id`` if ``SAML:nameid`` attribute is sent to the AWX node or it can be the username attribute, or a custom unique identifier. - - ``entity_id`` - the Entity ID provided by the Identity Provider administrator. The admin creates a SAML profile for AWX and it generates a unique URL. - - ``url`` - the Single Sign On (SSO) URL AWX redirects the user to, when SSO is activated. - - ``x509_cert`` - the certificate provided by the IdP admin generated from the SAML profile created on the Identity Provider. Remove the ``--BEGIN CERTIFICATE--`` and ``--END CERTIFICATE--`` headers, then enter the cert as one non-breaking string. - - Multiple SAML IdPs are supported. Some IdPs may provide user data using attribute names that differ from the default OIDs (https://github.com/omab/python-social-auth/blob/master/social/backends/saml.py). The SAML ``NameID`` is a special attribute used by some Identity Providers to tell the Service Provider (AWX cluster) what the unique user identifier is. If it is used, set the ``attr_user_permanent_id`` to ``name_id`` as shown in the example. Other attribute names may be overridden for each IdP as shown below. - -:: - - { - "myidp": { - "entity_id": "https://idp.example.com", - "url": "https://myidp.example.com/sso", - "x509cert": "" - }, - "onelogin": { - "entity_id": "https://app.onelogin.com/saml/metadata/123456", - "url": "https://example.onelogin.com/trust/saml2/http-post/sso/123456", - "x509cert": "", - "attr_user_permanent_id": "name_id", - "attr_first_name": "User.FirstName", - "attr_last_name": "User.LastName", - "attr_username": "User.email", - "attr_email": "User.email" - } - } - -.. image:: ../common/images/configure-awx-auth-saml-idps.png - :alt: Configuring SAML Identity Providers (IdPs) in AWX. - -.. warning:: - - Do not create a SAML user that shares the same email with another user (including a non-SAML user). Doing so will result in the accounts being merged. Be aware that this same behavior exists for System Admin users, thus a SAML login with the same email address as the System Admin user will login with System Admin privileges. For future reference, you can remove (or add) Admin Privileges based on SAML mappings, as described in subsequent steps. - - -.. note:: - - The IdP provides the email, last name and firstname using the well known SAML urn. The IdP uses a custom SAML attribute to identify a user, which is an attribute that AWX is unable to read. Instead, AWX can understand the unique identifier name, which is the URN. Use the URN listed in the SAML “Name” attribute for the user attributes as shown in the example below. - - .. image:: ../common/images/configure-awx-auth-saml-idps-urn.png - :alt: Configuring SAML Identity Providers (IdPs) in AWX using URNs. - -11. Optionally provide the **SAML Organization Map**. For further detail, see :ref:`ag_org_team_maps`. - -12. AWX can be configured to look for particular attributes that contain Team and Organization membership to associate with users when they log into AWX. The attribute names are defined in the **SAML Organization Attribute Mapping** and the **SAML Team Attribute Mapping** fields. - -**Example SAML Organization Attribute Mapping** - -Below is an example SAML attribute that embeds user organization membership in the attribute *member-of*. - -:: - - - - Engineering - IT - HR - Sales - - - Engineering - - - - -Below is the corresponding AWX configuration. - -:: - - { - "saml_attr": "member-of", - "saml_admin_attr": "admin-of", - "remove": true, - "remove_admins": false - } - - -``saml_attr``: is the SAML attribute name where the organization array can be found and ``remove`` is set to **True** to remove a user from all organizations before adding the user to the list of Organizations. To keep the user in whatever Organization(s) they are in while adding the user to the Organization(s) in the SAML attribute, set ``remove`` to **False**. - -``saml_admin_attr``: Similar to the ``saml_attr`` attribute, but instead of conveying organization membership, this attribute conveys admin organization permissions. - -**Example SAML Team Attribute Mapping** - -Below is another example of a SAML attribute that contains a Team membership in a list. - -:: - - - - member - staff - - - - -:: - - { - "saml_attr": "eduPersonAffiliation", - "remove": true, - "team_org_map": [ - { - "team": "member", - "organization": "Default1" - }, - { - "team": "staff", - "organization": "Default2" - } - ] - } - -- ``saml_attr``: The SAML attribute name where the team array can be found. -- ``remove``: Set ``remove`` to **True** to remove user from all Teams before adding the user to the list of Teams. To keep the user in whatever Team(s) they are in while adding the user to the Team(s) in the SAML attribute, set ``remove`` to **False**. -- ``team_org_map``: An array of dictionaries of the form ``{ "team": "", "organization": "" }`` that defines mapping from AWX Team -> AWX Organization. This is needed because the same named Team can exist in multiple Organizations in AWX. The organization to which a team listed in a SAML attribute belongs to, would be ambiguous without this mapping. - -You could create an alias to override both Teams and Orgs in the **SAML Team Attribute Mapping**. This option becomes very handy in cases when the SAML backend sends out complex group names, like in the example below: - -:: - - { - "remove": false, - "team_org_map": [ - { - "team": "internal:unix:domain:admins", - "organization": "Default", - "team_alias": "Administrators" - }, - { - "team": "Domain Users", - "organization_alias": "OrgAlias", - "organization": "Default" - } - ], - "saml_attr": "member-of" - } - -Once the user authenticates, AWX creates organization and team aliases, as expected. - - -13. Optionally provide team membership mapping in the **SAML Team Map** field. For further detail, see :ref:`ag_org_team_maps`. - -14. Optionally provide security settings in the **SAML Security Config** field. This field is the equivalent to the ``SOCIAL_AUTH_SAML_SECURITY_CONFIG`` field in the API. Refer to the `OneLogin's SAML Python Toolkit`_ for further detail. - -.. _`OneLogin's SAML Python Toolkit`: https://github.com/onelogin/python-saml#settings - -AWX uses the ``python-social-auth`` library when users log in through SAML. This library relies on the ``python-saml`` library to make available the settings for the next two optional fields, **SAML Service Provider Extra Configuration Data** and **SAML IDP to EXTRA_DATA Attribute Mapping**. - -15. The **SAML Service Provider Extra Configuration Data** field is equivalent to the ``SOCIAL_AUTH_SAML_SP_EXTRA`` in the API. Refer to the `python-saml library documentation`_ to learn about the valid service provider extra (``SP_EXTRA``) parameters. - -.. _`python-saml library documentation`: https://github.com/onelogin/python-saml#settings - -16. The **SAML IDP to EXTRA_DATA Attribute Mapping** field is equivalent to the ``SOCIAL_AUTH_SAML_EXTRA_DATA`` in the API. See Python's `SAML Advanced Settings`_ documentation for more information. - -.. _`SAML Advanced Settings`: https://python-social-auth.readthedocs.io/en/latest/backends/saml.html#advanced-settings - -.. _ag_auth_saml_user_flags_attr_map: - -17. The **SAML User Flags Attribute Mapping** field allows you to map SAML roles and attributes to special user flags. The following attributes are valid in this field: - -- ``is_superuser_role``: Specifies one or more SAML roles which will grant a user the superuser flag -- ``is_superuser_attr``: Specifies a SAML attribute which will grant a user the superuser flag -- ``is_superuser_value``: Specifies one or more values required for ``is_superuser_attr`` that is required for the user to be a superuser -- ``remove_superusers``: Boolean indicating if the superuser flag should be removed for users or not. Defaults to ``true``. (See below for more details) -- ``is_system_auditor_role``: Specifies one or more SAML roles which will grant a user the system auditor flag -- ``is_system_auditor_attr``: Specifies a SAML attribute which will grant a user the system auditor flag -- ``is_system_auditor_value``: Specifies one or more values required for ``is_system_auditor_attr`` that is required for the user to be a system auditor -- ``remove_system_auditors``: Boolean indicating if the system_auditor flag should be removed for users or not. Defaults to ``true``. (See below for more details) - - -The ``role`` and ``value`` fields are lists and are `or` logic. So if you specify two roles: `[ "Role 1", "Role 2" ]` and the SAML user has either role the logic will consider them to have the required role for the flag. This is the same with the ``value`` field, if you specify: `[ "Value 1", "Value 2"]` and the SAML user has either value for their attribute the logic will consider their attribute value to have matched. - -If ``role`` and ``attr`` are both specified for either ``superuser`` or ``system_auditor``, the settings for ``attr`` will take precedence over a ``role``. System Admin and System Auditor roles are evaluated at login for a SAML user. If you grant a SAML user one of these roles through the UI and not through the SAML settings, the roles will be removed on the user's next login unless the ``remove`` flag is set to false. The remove flag, if ``false``, will never allow the SAML adapter to remove the corresponding flag from a user. The following table describes how the logic works. - -+-----------------------+-----------+-----------------------------+-------------+---------------+------------+ -| Has one or more roles | Has Attr | Has one or more Attr Values | Remove Flag | Previous Flag | Is Flagged | -+=======================+===========+=============================+=============+===============+============+ -| No | No | N/A | True | False | No | -+-----------------------+-----------+-----------------------------+-------------+---------------+------------+ -| No | No | N/A | False | False | No | -+-----------------------+-----------+-----------------------------+-------------+---------------+------------+ -| No | No | N/A | True | True | No | -+-----------------------+-----------+-----------------------------+-------------+---------------+------------+ -| No | No | N/A | False | True | Yes | -+-----------------------+-----------+-----------------------------+-------------+---------------+------------+ -| Yes | No | N/A | True | False | Yes | -+-----------------------+-----------+-----------------------------+-------------+---------------+------------+ -| Yes | No | N/A | False | False | Yes | -+-----------------------+-----------+-----------------------------+-------------+---------------+------------+ -| Yes | No | N/A | True | True | Yes | -+-----------------------+-----------+-----------------------------+-------------+---------------+------------+ -| Yes | No | N/A | False | True | Yes | -+-----------------------+-----------+-----------------------------+-------------+---------------+------------+ -| No | Yes | Yes | True | False | Yes | -+-----------------------+-----------+-----------------------------+-------------+---------------+------------+ -| No | Yes | Yes | False | False | Yes | -+-----------------------+-----------+-----------------------------+-------------+---------------+------------+ -| No | Yes | Yes | True | True | Yes | -+-----------------------+-----------+-----------------------------+-------------+---------------+------------+ -| No | Yes | Yes | False | True | Yes | -+-----------------------+-----------+-----------------------------+-------------+---------------+------------+ -| No | Yes | No | True | False | No | -+-----------------------+-----------+-----------------------------+-------------+---------------+------------+ -| No | Yes | No | False | False | No | -+-----------------------+-----------+-----------------------------+-------------+---------------+------------+ -| No | Yes | No | True | True | No | -+-----------------------+-----------+-----------------------------+-------------+---------------+------------+ -| No | Yes | No | False | True | Yes | -+-----------------------+-----------+-----------------------------+-------------+---------------+------------+ -| No | Yes | Unset | True | False | Yes | -+-----------------------+-----------+-----------------------------+-------------+---------------+------------+ -| No | Yes | Unset | False | False | Yes | -+-----------------------+-----------+-----------------------------+-------------+---------------+------------+ -| No | Yes | Unset | True | True | Yes | -+-----------------------+-----------+-----------------------------+-------------+---------------+------------+ -| No | Yes | Unset | False | True | Yes | -+-----------------------+-----------+-----------------------------+-------------+---------------+------------+ -| Yes | Yes | Yes | True | False | Yes | -+-----------------------+-----------+-----------------------------+-------------+---------------+------------+ -| Yes | Yes | Yes | False | False | Yes | -+-----------------------+-----------+-----------------------------+-------------+---------------+------------+ -| Yes | Yes | Yes | True | True | Yes | -+-----------------------+-----------+-----------------------------+-------------+---------------+------------+ -| Yes | Yes | Yes | False | True | Yes | -+-----------------------+-----------+-----------------------------+-------------+---------------+------------+ -| Yes | Yes | No | True | False | No | -+-----------------------+-----------+-----------------------------+-------------+---------------+------------+ -| Yes | Yes | No | False | False | No | -+-----------------------+-----------+-----------------------------+-------------+---------------+------------+ -| Yes | Yes | No | True | True | No | -+-----------------------+-----------+-----------------------------+-------------+---------------+------------+ -| Yes | Yes | No | False | True | Yes | -+-----------------------+-----------+-----------------------------+-------------+---------------+------------+ -| Yes | Yes | Unset | True | False | Yes | -+-----------------------+-----------+-----------------------------+-------------+---------------+------------+ -| Yes | Yes | Unset | False | False | Yes | -+-----------------------+-----------+-----------------------------+-------------+---------------+------------+ -| Yes | Yes | Unset | True | True | Yes | -+-----------------------+-----------+-----------------------------+-------------+---------------+------------+ -| Yes | Yes | Unset | False | True | Yes | -+-----------------------+-----------+-----------------------------+-------------+---------------+------------+ - -Each time a SAML user authenticates to AWX, these checks will be performed and the user flags will be altered as needed. If ``System Administrator`` or ``System Auditor`` is set for a SAML user within the UI, the SAML adapter will override the UI setting based on the rules above. If you would prefer that the user flags for SAML users do not get removed when a SAML user logs in, you can set the ``remove_`` flag to ``false``. With the remove flag set to ``false``, a user flag set to ``true`` through either the UI, API or SAML adapter will not be removed. However, if a user does not have the flag, and the above rules determine the flag should be added, it will be added, even if the flag is ``false``. - -Example:: - - { - "is_superuser_attr": "blueGroups", - "is_superuser_role": ["is_superuser"], - "is_superuser_value": ["cn=My-Sys-Admins,ou=memberlist,ou=mygroups,o=myco.com"], - "is_system_auditor_attr": "blueGroups", - "is_system_auditor_role": ["is_system_auditor"], - "is_system_auditor_value": ["cn=My-Auditors,ou=memberlist,ou=mygroups,o=myco.com"] - } - -18. Click **Save** when done. - -19. To verify that the authentication was configured correctly, load the auto-generated URL found in the **SAML Service Provider Metadata URL** into a browser. It should output XML output, otherwise, it is not configured correctly. - - Alternatively, logout of AWX and the login screen will now display the SAML logo to indicate it as a alternate method of logging into AWX. - - .. image:: ../common/images/configure-awx-auth-saml-logo.png - :alt: AWX login screen displaying the SAML logo for authentication. - - -Transparent SAML Logins -^^^^^^^^^^^^^^^^^^^^^^^^ - -.. index:: - pair: authentication; SAML - pair: SAML; transparent - -For transparent logins to work, you must first get IdP-initiated logins to work. To achieve this: - -1. Set the ``RelayState`` on the IdP to the key of the IdP definition in the ``SAML Enabled Identity Providers`` field as previously described. In the example given above, ``RelayState`` would need to be either ``myidp`` or ``onelogin``. - -2. Once this is working, specify the redirect URL for non-logged-in users to somewhere other than the default AWX login page by using the **Login redirect override URL** field in the Miscellaneous Authentication settings window of the **Settings** menu, accessible from the left navigation bar. This should be set to ``/sso/login/saml/?idp=`` for transparent SAML login, as shown in the example. - -.. image:: ../common/images/configure-awx-system-login-redirect-url.png - :alt: Configuring the login redirect URL in AWX Miscellaneous Authentication Settings. - -.. note:: - - The above is a sample of a typical IdP format, but may not be the correct format for your particular case. You may need to reach out to your IdP for the correct transparent redirect URL as that URL is not the same for all IdPs. - -3. After transparent SAML login is configured, to log in using local credentials or a different SSO, go directly to ``https:///login``. This provides the standard AWX login page, including SSO authentication buttons, and allows you to log in with any configured method. - - -Enabling Logging for SAML -^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -You can enable logging messages for the SAML adapter the same way you can enable logging for LDAP. Refer to the :ref:`ldap_logging` section. - - .. _ag_auth_tacacs: TACACS+ settings diff --git a/docs/docsite/rst/administration/security_best_practices.rst b/docs/docsite/rst/administration/security_best_practices.rst index 8a75fa73b918..8695082fbf86 100644 --- a/docs/docsite/rst/administration/security_best_practices.rst +++ b/docs/docsite/rst/administration/security_best_practices.rst @@ -82,7 +82,7 @@ Do not disable SELinux, and do not disable AWX’s existing multi-tenant contain External account stores ^^^^^^^^^^^^^^^^^^^^^^^^^ -Maintaining a full set of users just in AWX can be a time-consuming task in a large organization, prone to error. AWX supports connecting to external account sources via :ref:`LDAP `, :ref:`SAML 2.0 `, and certain :ref:`OAuth providers `. Using this eliminates a source of error when working with permissions. +Maintaining a full set of users just in AWX can be a time-consuming task in a large organization, prone to error. AWX supports connecting to external account sources via :ref:`LDAP ` and certain :ref:`OAuth providers `. Using this eliminates a source of error when working with permissions. .. _ag_security_django_password: diff --git a/docs/docsite/rst/administration/social_auth.rst b/docs/docsite/rst/administration/social_auth.rst index b9c33cde2cff..9979fb0d3934 100644 --- a/docs/docsite/rst/administration/social_auth.rst +++ b/docs/docsite/rst/administration/social_auth.rst @@ -11,12 +11,10 @@ Authentication methods help simplify logins for end users--offering single sign- Account authentication can be configured in the AWX User Interface and saved to the PostgreSQL database. For instructions, refer to the :ref:`ag_configure_awx` section. -Account authentication in AWX can be configured to centrally use OAuth2, while enterprise-level account authentication can be configured for :ref:`Azure `, :ref:`RADIUS `, :ref:`SAML `, or even :ref:`LDAP ` as a source for authentication information. See :ref:`ag_ent_auth` for more detail. +Account authentication in AWX can be configured to centrally use OAuth2, while enterprise-level account authentication can be configured for :ref:`Azure `, :ref:`RADIUS `, or even :ref:`LDAP ` as a source for authentication information. See :ref:`ag_ent_auth` for more detail. For websites, such as Microsoft Azure, Google or GitHub, that provide account information, account information is often implemented using the OAuth standard. OAuth is a secure authorization protocol which is commonly used in conjunction with account authentication to grant 3rd party applications a "session token" allowing them to make API calls to providers on the user’s behalf. -Security Assertion Markup Language (:ref:`SAML `) is an XML-based, open-standard data format for exchanging account authentication and authorization data between an identity provider and a service provider. - The :ref:`RADIUS ` distributed client/server system allows you to secure networks against unauthorized access and can be implemented in network environments requiring high levels of security while maintaining network access for remote users. @@ -341,7 +339,6 @@ Organization mappings may be specified separately for each account authenticatio SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP = {} SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP = {} SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP = {} - SOCIAL_AUTH_SAML_ORGANIZATION_MAP = {} Team mapping @@ -387,7 +384,6 @@ Team mappings may be specified separately for each account authentication backen SOCIAL_AUTH_GITHUB_TEAM_MAP = {} SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP = {} SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP = {} - SOCIAL_AUTH_SAML_TEAM_MAP = {} Uncomment the line below (i.e. set ``SOCIAL_AUTH_USER_FIELDS`` to an empty list) to prevent new user accounts from being created. Only users who have previously logged in to AWX using social or enterprise-level authentication or have a user account with a matching email address will be able to login. diff --git a/docs/docsite/rst/release_notes/known_issues.rst b/docs/docsite/rst/release_notes/known_issues.rst index 26f89394ef7e..ae685f152c2e 100644 --- a/docs/docsite/rst/release_notes/known_issues.rst +++ b/docs/docsite/rst/release_notes/known_issues.rst @@ -27,7 +27,6 @@ Known Issues pair: known issues; session limit pair: known issues; Ansible Azure dependencies pair: known issues; authentication (reactive user) - pair: known issues; SAML issues pair: known issues; user cannot log in using authentication pair: known issues; login problems with social authentication pair: known issues; OAuth account recreation @@ -111,20 +110,6 @@ Potential security issue using ``X_FORWARDED_FOR`` in ``REMOTE_HOST_HEADERS`` If placing AWX nodes behind some sort of proxy, this may pose a security issue. This approach assumes traffic is always flowing exclusively through your load balancer, and that traffic that circumvents the load balancer is suspect to ``X-Forwarded-For`` header spoofing. -Server error when accessing SAML metadata via hostname -========================================================= - -When AWX is accessed via hostname only (e.g. https://my-little-awx), trying to read the SAML metadata from /sso/metadata/saml/ generates a ``sp_acs_url_invalid`` server error. - -A configuration in which uses SAML when accessing AWX via hostname only instead of an FQDN, is not supported. Doing so will generate an error that is captured in the browser with full traceback information. - - -SAML authentication revokes admin role upon login -================================================== - -Older versions of AWX, the SAML adapter did not evaluate the System Auditor or System Admin roles for a user logging in. Because of this, the login process would not change a user's system roles that were granted through the User Interface. The adapter now has a setting called **SAML User Flags Attribute Mapping** to grant users logging in these roles based on either SAML attributes or roles, and the adapter defaults to removing these roles if unspecified akin to the LDAP adapter. Refer to the :ref:`logic table ` that shows the relationship between how the role, attribute, and attribute value settings are configured and whether or not a user will be granted the System Admin/Auditor roles. - - Live events status indicators =============================== From 01a18bec6f593411abbe8637d0dff61a8bae816d Mon Sep 17 00:00:00 2001 From: Djebran Lezzoum Date: Wed, 2 Oct 2024 15:40:16 +0200 Subject: [PATCH 4/5] Remove LDAP authentication (#15546) Remove LDAP authentication from AWX --- Makefile | 8 +- awx/api/serializers.py | 39 +- awx/api/views/root.py | 9 - .../migrations/0006_v331_ldap_group_type.py | 6 +- .../migrations/0011_remove_ldap_auth_conf.py | 115 ++++++ awx/conf/migrations/_ldap_group_type.py | 31 -- awx/conf/signals.py | 2 +- awx/conf/tests/functional/test_migrations.py | 25 -- awx/main/access.py | 5 +- .../management/commands/dump_auth_config.py | 69 +--- awx/main/middleware.py | 2 +- awx/main/migrations/0196_delete_profile.py | 16 + awx/main/models/__init__.py | 3 +- awx/main/models/organization.py | 22 +- .../tests/functional/api/test_settings.py | 76 ---- awx/main/tests/functional/test_ldap.py | 103 ----- .../unit/commands/test_dump_auth_config.py | 43 --- awx/settings/defaults.py | 17 - awx/sso/backends.py | 264 ------------- awx/sso/common.py | 17 +- awx/sso/conf.py | 305 +-------------- awx/sso/fields.py | 343 +---------------- awx/sso/ldap_group_types.py | 73 ---- awx/sso/tests/functional/test_backends.py | 115 ------ awx/sso/tests/functional/test_common.py | 24 +- awx/sso/tests/functional/test_ldap.py | 19 - awx/sso/tests/unit/test_fields.py | 44 +-- awx/sso/tests/unit/test_ldap.py | 25 -- awx/sso/validators.py | 60 --- awx_collection/plugins/modules/settings.py | 15 - awx_collection/test/awx/test_settings.py | 30 -- awxkit/awxkit/api/pages/settings.py | 1 - awxkit/awxkit/api/resources.py | 1 - docs/auth/README.md | 3 +- docs/auth/ldap.md | 68 ---- .../configure_awx_authentication.rst | 5 +- docs/docsite/rst/administration/ent_auth.rst | 11 - docs/docsite/rst/administration/index.rst | 1 - docs/docsite/rst/administration/ldap_auth.rst | 361 ------------------ docs/docsite/rst/administration/logging.rst | 8 - .../rst/administration/oauth2_token_auth.rst | 2 +- .../rst/administration/performance.rst | 11 +- .../rst/administration/secret_handling.rst | 2 +- .../security_best_practices.rst | 2 +- .../rst/administration/social_auth.rst | 2 +- .../rst/administration/troubleshooting.rst | 3 - .../rst/release_notes/known_issues.rst | 8 - docs/docsite/rst/userguide/overview.rst | 2 +- docs/docsite/rst/userguide/rbac.rst | 2 +- licenses/django-auth-ldap.txt | 23 -- licenses/python-ldap.txt | 73 ---- requirements/requirements.in | 2 - requirements/requirements.txt | 9 - requirements/requirements_dev.txt | 1 - .../roles/dockerfile/templates/Dockerfile.j2 | 2 - tools/docker-compose/README.md | 85 +---- tools/docker-compose/ansible/plumb_ldap.yml | 32 -- .../ansible/roles/sources/defaults/main.yml | 9 - .../ansible/roles/sources/tasks/ldap.yml | 21 - .../ansible/roles/sources/tasks/main.yml | 4 - .../sources/templates/docker-compose.yml.j2 | 30 -- .../roles/sources/templates/ldap.ldif.j2 | 99 ----- .../sources/templates/local_settings.py.j2 | 4 - .../ansible/roles/vault/defaults/main.yml | 3 - .../ansible/roles/vault/tasks/initialize.yml | 68 ---- .../ansible/roles/vault/tasks/plumb.yml | 50 --- .../ansible/templates/ldap_settings.json.j2 | 52 --- 67 files changed, 172 insertions(+), 2813 deletions(-) create mode 100644 awx/conf/migrations/0011_remove_ldap_auth_conf.py delete mode 100644 awx/conf/migrations/_ldap_group_type.py delete mode 100644 awx/conf/tests/functional/test_migrations.py create mode 100644 awx/main/migrations/0196_delete_profile.py delete mode 100644 awx/main/tests/functional/test_ldap.py delete mode 100644 awx/sso/ldap_group_types.py delete mode 100644 awx/sso/tests/functional/test_backends.py delete mode 100644 awx/sso/tests/functional/test_ldap.py delete mode 100644 awx/sso/tests/unit/test_ldap.py delete mode 100644 docs/auth/ldap.md delete mode 100644 docs/docsite/rst/administration/ldap_auth.rst delete mode 100644 licenses/django-auth-ldap.txt delete mode 100644 licenses/python-ldap.txt delete mode 100644 tools/docker-compose/ansible/plumb_ldap.yml delete mode 100644 tools/docker-compose/ansible/roles/sources/tasks/ldap.yml delete mode 100644 tools/docker-compose/ansible/roles/sources/templates/ldap.ldif.j2 delete mode 100644 tools/docker-compose/ansible/templates/ldap_settings.json.j2 diff --git a/Makefile b/Makefile index 60aae0395cb0..257590f091c8 100644 --- a/Makefile +++ b/Makefile @@ -33,8 +33,6 @@ MAIN_NODE_TYPE ?= hybrid PGBOUNCER ?= false # If set to true docker-compose will also start a keycloak instance KEYCLOAK ?= false -# If set to true docker-compose will also start an ldap instance -LDAP ?= false # If set to true docker-compose will also start a splunk instance SPLUNK ?= false # If set to true docker-compose will also start a prometheus instance @@ -508,7 +506,6 @@ docker-compose-sources: .git/hooks/pre-commit -e minikube_container_group=$(MINIKUBE_CONTAINER_GROUP) \ -e enable_pgbouncer=$(PGBOUNCER) \ -e enable_keycloak=$(KEYCLOAK) \ - -e enable_ldap=$(LDAP) \ -e enable_splunk=$(SPLUNK) \ -e enable_prometheus=$(PROMETHEUS) \ -e enable_grafana=$(GRAFANA) \ @@ -525,8 +522,7 @@ docker-compose: awx/projects docker-compose-sources ansible-galaxy install --ignore-certs -r tools/docker-compose/ansible/requirements.yml; $(ANSIBLE_PLAYBOOK) -i tools/docker-compose/inventory tools/docker-compose/ansible/initialize_containers.yml \ -e enable_vault=$(VAULT) \ - -e vault_tls=$(VAULT_TLS) \ - -e enable_ldap=$(LDAP); \ + -e vault_tls=$(VAULT_TLS); \ $(MAKE) docker-compose-up docker-compose-up: @@ -598,7 +594,7 @@ docker-clean: -$(foreach image_id,$(shell docker images --filter=reference='*/*/*awx_devel*' --filter=reference='*/*awx_devel*' --filter=reference='*awx_devel*' -aq),docker rmi --force $(image_id);) docker-clean-volumes: docker-compose-clean docker-compose-container-group-clean - docker volume rm -f tools_var_lib_awx tools_awx_db tools_awx_db_15 tools_vault_1 tools_ldap_1 tools_grafana_storage tools_prometheus_storage $(shell docker volume ls --filter name=tools_redis_socket_ -q) + docker volume rm -f tools_var_lib_awx tools_awx_db tools_awx_db_15 tools_vault_1 tools_grafana_storage tools_prometheus_storage $(shell docker volume ls --filter name=tools_redis_socket_ -q) docker-refresh: docker-clean docker-compose diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 231bf3bbcda0..e167f463ec5f 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -961,7 +961,6 @@ def get_types(self): class UserSerializer(BaseSerializer): password = serializers.CharField(required=False, default='', help_text=_('Field used to change the password.')) - ldap_dn = serializers.CharField(source='profile.ldap_dn', read_only=True) external_account = serializers.SerializerMethodField(help_text=_('Set if the account is managed by an external service')) is_system_auditor = serializers.BooleanField(default=False) show_capabilities = ['edit', 'delete'] @@ -979,7 +978,6 @@ class Meta: 'is_superuser', 'is_system_auditor', 'password', - 'ldap_dn', 'last_login', 'external_account', ) @@ -1028,8 +1026,10 @@ def validate_password(self, value): def _update_password(self, obj, new_password): # For now we're not raising an error, just not saving password for - # users managed by LDAP who already have an unusable password set. - # Get external password will return something like ldap or enterprise or None if the user isn't external. We only want to allow a password update for a None option + # users managed by external authentication services (who already have an unusable password set). + # get_external_account function will return something like social or enterprise when the user is external, + # and return None when the user isn't external. + # We want to allow a password update only for non-external accounts. if new_password and new_password != '$encrypted$' and not self.get_external_account(obj): obj.set_password(new_password) obj.save(update_fields=['password']) @@ -1085,37 +1085,6 @@ def get_related(self, obj): ) return res - def _validate_ldap_managed_field(self, value, field_name): - if not getattr(settings, 'AUTH_LDAP_SERVER_URI', None): - return value - try: - is_ldap_user = bool(self.instance and self.instance.profile.ldap_dn) - except AttributeError: - is_ldap_user = False - if is_ldap_user: - ldap_managed_fields = ['username'] - ldap_managed_fields.extend(getattr(settings, 'AUTH_LDAP_USER_ATTR_MAP', {}).keys()) - ldap_managed_fields.extend(getattr(settings, 'AUTH_LDAP_USER_FLAGS_BY_GROUP', {}).keys()) - if field_name in ldap_managed_fields: - if value != getattr(self.instance, field_name): - raise serializers.ValidationError(_('Unable to change %s on user managed by LDAP.') % field_name) - return value - - def validate_username(self, value): - return self._validate_ldap_managed_field(value, 'username') - - def validate_first_name(self, value): - return self._validate_ldap_managed_field(value, 'first_name') - - def validate_last_name(self, value): - return self._validate_ldap_managed_field(value, 'last_name') - - def validate_email(self, value): - return self._validate_ldap_managed_field(value, 'email') - - def validate_is_superuser(self, value): - return self._validate_ldap_managed_field(value, 'is_superuser') - class UserActivityStreamSerializer(UserSerializer): """Changes to system auditor status are shown as separate entries, diff --git a/awx/api/views/root.py b/awx/api/views/root.py index e55461923e8b..d0f3a88e4c21 100644 --- a/awx/api/views/root.py +++ b/awx/api/views/root.py @@ -295,15 +295,6 @@ def get(self, request, format=None): become_methods=PRIVILEGE_ESCALATION_METHODS, ) - # If LDAP is enabled, user_ldap_fields will return a list of field - # names that are managed by LDAP and should be read-only for users with - # a non-empty ldap_dn attribute. - if getattr(settings, 'AUTH_LDAP_SERVER_URI', None): - user_ldap_fields = ['username', 'password'] - user_ldap_fields.extend(getattr(settings, 'AUTH_LDAP_USER_ATTR_MAP', {}).keys()) - user_ldap_fields.extend(getattr(settings, 'AUTH_LDAP_USER_FLAGS_BY_GROUP', {}).keys()) - data['user_ldap_fields'] = user_ldap_fields - if ( request.user.is_superuser or request.user.is_system_auditor diff --git a/awx/conf/migrations/0006_v331_ldap_group_type.py b/awx/conf/migrations/0006_v331_ldap_group_type.py index 9b7bf4e6ecc1..f70b3db273c1 100644 --- a/awx/conf/migrations/0006_v331_ldap_group_type.py +++ b/awx/conf/migrations/0006_v331_ldap_group_type.py @@ -1,13 +1,11 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -# AWX -from awx.conf.migrations._ldap_group_type import fill_ldap_group_type_params - from django.db import migrations class Migration(migrations.Migration): dependencies = [('conf', '0005_v330_rename_two_session_settings')] - operations = [migrations.RunPython(fill_ldap_group_type_params)] + # this migration is doing nothing, and is here to preserve migrations files integrity + operations = [] diff --git a/awx/conf/migrations/0011_remove_ldap_auth_conf.py b/awx/conf/migrations/0011_remove_ldap_auth_conf.py new file mode 100644 index 000000000000..e8955635af63 --- /dev/null +++ b/awx/conf/migrations/0011_remove_ldap_auth_conf.py @@ -0,0 +1,115 @@ +from django.db import migrations + +LDAP_AUTH_CONF_KEYS = [ + 'AUTH_LDAP_SERVER_URI', + 'AUTH_LDAP_BIND_DN', + 'AUTH_LDAP_BIND_PASSWORD', + 'AUTH_LDAP_START_TLS', + 'AUTH_LDAP_CONNECTION_OPTIONS', + 'AUTH_LDAP_USER_SEARCH', + 'AUTH_LDAP_USER_DN_TEMPLATE', + 'AUTH_LDAP_USER_ATTR_MAP', + 'AUTH_LDAP_GROUP_SEARCH', + 'AUTH_LDAP_GROUP_TYPE', + 'AUTH_LDAP_GROUP_TYPE_PARAMS', + 'AUTH_LDAP_REQUIRE_GROUP', + 'AUTH_LDAP_DENY_GROUP', + 'AUTH_LDAP_USER_FLAGS_BY_GROUP', + 'AUTH_LDAP_ORGANIZATION_MAP', + 'AUTH_LDAP_TEAM_MAP', + 'AUTH_LDAP_1_SERVER_URI', + 'AUTH_LDAP_1_BIND_DN', + 'AUTH_LDAP_1_BIND_PASSWORD', + 'AUTH_LDAP_1_START_TLS', + 'AUTH_LDAP_1_CONNECTION_OPTIONS', + 'AUTH_LDAP_1_USER_SEARCH', + 'AUTH_LDAP_1_USER_DN_TEMPLATE', + 'AUTH_LDAP_1_USER_ATTR_MAP', + 'AUTH_LDAP_1_GROUP_SEARCH', + 'AUTH_LDAP_1_GROUP_TYPE', + 'AUTH_LDAP_1_GROUP_TYPE_PARAMS', + 'AUTH_LDAP_1_REQUIRE_GROUP', + 'AUTH_LDAP_1_DENY_GROUP', + 'AUTH_LDAP_1_USER_FLAGS_BY_GROUP', + 'AUTH_LDAP_1_ORGANIZATION_MAP', + 'AUTH_LDAP_1_TEAM_MAP', + 'AUTH_LDAP_2_SERVER_URI', + 'AUTH_LDAP_2_BIND_DN', + 'AUTH_LDAP_2_BIND_PASSWORD', + 'AUTH_LDAP_2_START_TLS', + 'AUTH_LDAP_2_CONNECTION_OPTIONS', + 'AUTH_LDAP_2_USER_SEARCH', + 'AUTH_LDAP_2_USER_DN_TEMPLATE', + 'AUTH_LDAP_2_USER_ATTR_MAP', + 'AUTH_LDAP_2_GROUP_SEARCH', + 'AUTH_LDAP_2_GROUP_TYPE', + 'AUTH_LDAP_2_GROUP_TYPE_PARAMS', + 'AUTH_LDAP_2_REQUIRE_GROUP', + 'AUTH_LDAP_2_DENY_GROUP', + 'AUTH_LDAP_2_USER_FLAGS_BY_GROUP', + 'AUTH_LDAP_2_ORGANIZATION_MAP', + 'AUTH_LDAP_2_TEAM_MAP', + 'AUTH_LDAP_3_SERVER_URI', + 'AUTH_LDAP_3_BIND_DN', + 'AUTH_LDAP_3_BIND_PASSWORD', + 'AUTH_LDAP_3_START_TLS', + 'AUTH_LDAP_3_CONNECTION_OPTIONS', + 'AUTH_LDAP_3_USER_SEARCH', + 'AUTH_LDAP_3_USER_DN_TEMPLATE', + 'AUTH_LDAP_3_USER_ATTR_MAP', + 'AUTH_LDAP_3_GROUP_SEARCH', + 'AUTH_LDAP_3_GROUP_TYPE', + 'AUTH_LDAP_3_GROUP_TYPE_PARAMS', + 'AUTH_LDAP_3_REQUIRE_GROUP', + 'AUTH_LDAP_3_DENY_GROUP', + 'AUTH_LDAP_3_USER_FLAGS_BY_GROUP', + 'AUTH_LDAP_3_ORGANIZATION_MAP', + 'AUTH_LDAP_3_TEAM_MAP', + 'AUTH_LDAP_4_SERVER_URI', + 'AUTH_LDAP_4_BIND_DN', + 'AUTH_LDAP_4_BIND_PASSWORD', + 'AUTH_LDAP_4_START_TLS', + 'AUTH_LDAP_4_CONNECTION_OPTIONS', + 'AUTH_LDAP_4_USER_SEARCH', + 'AUTH_LDAP_4_USER_DN_TEMPLATE', + 'AUTH_LDAP_4_USER_ATTR_MAP', + 'AUTH_LDAP_4_GROUP_SEARCH', + 'AUTH_LDAP_4_GROUP_TYPE', + 'AUTH_LDAP_4_GROUP_TYPE_PARAMS', + 'AUTH_LDAP_4_REQUIRE_GROUP', + 'AUTH_LDAP_4_DENY_GROUP', + 'AUTH_LDAP_4_USER_FLAGS_BY_GROUP', + 'AUTH_LDAP_4_ORGANIZATION_MAP', + 'AUTH_LDAP_4_TEAM_MAP', + 'AUTH_LDAP_5_SERVER_URI', + 'AUTH_LDAP_5_BIND_DN', + 'AUTH_LDAP_5_BIND_PASSWORD', + 'AUTH_LDAP_5_START_TLS', + 'AUTH_LDAP_5_CONNECTION_OPTIONS', + 'AUTH_LDAP_5_USER_SEARCH', + 'AUTH_LDAP_5_USER_DN_TEMPLATE', + 'AUTH_LDAP_5_USER_ATTR_MAP', + 'AUTH_LDAP_5_GROUP_SEARCH', + 'AUTH_LDAP_5_GROUP_TYPE', + 'AUTH_LDAP_5_GROUP_TYPE_PARAMS', + 'AUTH_LDAP_5_REQUIRE_GROUP', + 'AUTH_LDAP_5_DENY_GROUP', + 'AUTH_LDAP_5_USER_FLAGS_BY_GROUP', + 'AUTH_LDAP_5_ORGANIZATION_MAP', + 'AUTH_LDAP_5_TEAM_MAP', +] + + +def remove_ldap_auth_conf(apps, scheme_editor): + setting = apps.get_model('conf', 'Setting') + setting.objects.filter(key__in=LDAP_AUTH_CONF_KEYS).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ('conf', '0010_change_to_JSONField'), + ] + + operations = [ + migrations.RunPython(remove_ldap_auth_conf), + ] diff --git a/awx/conf/migrations/_ldap_group_type.py b/awx/conf/migrations/_ldap_group_type.py deleted file mode 100644 index 378f934342fb..000000000000 --- a/awx/conf/migrations/_ldap_group_type.py +++ /dev/null @@ -1,31 +0,0 @@ -import inspect - -from django.conf import settings - -import logging - - -logger = logging.getLogger('awx.conf.migrations') - - -def fill_ldap_group_type_params(apps, schema_editor): - group_type = getattr(settings, 'AUTH_LDAP_GROUP_TYPE', None) - Setting = apps.get_model('conf', 'Setting') - - group_type_params = {'name_attr': 'cn', 'member_attr': 'member'} - qs = Setting.objects.filter(key='AUTH_LDAP_GROUP_TYPE_PARAMS') - entry = None - if qs.exists(): - entry = qs[0] - group_type_params = entry.value - else: - return # for new installs we prefer to use the default value - - init_attrs = set(inspect.getfullargspec(group_type.__init__).args[1:]) - for k in list(group_type_params.keys()): - if k not in init_attrs: - del group_type_params[k] - - entry.value = group_type_params - logger.warning(f'Migration updating AUTH_LDAP_GROUP_TYPE_PARAMS with value {entry.value}') - entry.save() diff --git a/awx/conf/signals.py b/awx/conf/signals.py index d8297becb40e..d7868e4faa4f 100644 --- a/awx/conf/signals.py +++ b/awx/conf/signals.py @@ -73,6 +73,6 @@ def disable_local_auth(**kwargs): logger.warning("Triggering token invalidation for local users.") - qs = User.objects.filter(profile__ldap_dn='', enterprise_auth__isnull=True, social_auth__isnull=True) + qs = User.objects.filter(enterprise_auth__isnull=True, social_auth__isnull=True) revoke_tokens(RefreshToken.objects.filter(revoked=None, user__in=qs)) revoke_tokens(OAuth2AccessToken.objects.filter(user__in=qs)) diff --git a/awx/conf/tests/functional/test_migrations.py b/awx/conf/tests/functional/test_migrations.py deleted file mode 100644 index d3fddb292bd1..000000000000 --- a/awx/conf/tests/functional/test_migrations.py +++ /dev/null @@ -1,25 +0,0 @@ -import pytest - -from awx.conf.migrations._ldap_group_type import fill_ldap_group_type_params -from awx.conf.models import Setting - -from django.apps import apps - - -@pytest.mark.django_db -def test_fill_group_type_params_no_op(): - fill_ldap_group_type_params(apps, 'dont-use-me') - assert Setting.objects.count() == 0 - - -@pytest.mark.django_db -def test_keep_old_setting_with_default_value(): - Setting.objects.create(key='AUTH_LDAP_GROUP_TYPE', value={'name_attr': 'cn', 'member_attr': 'member'}) - fill_ldap_group_type_params(apps, 'dont-use-me') - assert Setting.objects.count() == 1 - s = Setting.objects.first() - assert s.value == {'name_attr': 'cn', 'member_attr': 'member'} - - -# NOTE: would be good to test the removal of attributes by migration -# but this requires fighting with the validator and is not done here diff --git a/awx/main/access.py b/awx/main/access.py index 3a217fe2afa4..74c604436807 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -642,10 +642,7 @@ class UserAccess(BaseAccess): """ model = User - prefetch_related = ( - 'profile', - 'resource', - ) + prefetch_related = ('resource',) def filtered_queryset(self): if settings.ORG_ADMINS_CAN_SEE_ALL_USERS and (self.user.admin_of_organizations.exists() or self.user.auditor_of_organizations.exists()): diff --git a/awx/main/management/commands/dump_auth_config.py b/awx/main/management/commands/dump_auth_config.py index 45afc9b41d41..6434b01db939 100644 --- a/awx/main/management/commands/dump_auth_config.py +++ b/awx/main/management/commands/dump_auth_config.py @@ -1,7 +1,6 @@ import json import os import sys -import re from typing import Any from django.core.management.base import BaseCommand @@ -11,7 +10,7 @@ class Command(BaseCommand): - help = 'Dump the current auth configuration in django_ansible_base.authenticator format, currently supports LDAP and SAML' + help = 'Dump the current auth configuration in django_ansible_base.authenticator format, currently supports SAML' DAB_SAML_AUTHENTICATOR_KEYS = { "SP_ENTITY_ID": True, @@ -27,20 +26,6 @@ class Command(BaseCommand): "CALLBACK_URL": False, } - DAB_LDAP_AUTHENTICATOR_KEYS = { - "SERVER_URI": True, - "BIND_DN": False, - "BIND_PASSWORD": False, - "CONNECTION_OPTIONS": False, - "GROUP_TYPE": True, - "GROUP_TYPE_PARAMS": True, - "GROUP_SEARCH": False, - "START_TLS": False, - "USER_DN_TEMPLATE": True, - "USER_ATTR_MAP": True, - "USER_SEARCH": False, - } - def is_enabled(self, settings, keys): missing_fields = [] for key, required in keys.items(): @@ -50,41 +35,6 @@ def is_enabled(self, settings, keys): return False, missing_fields return True, None - def get_awx_ldap_settings(self) -> dict[str, dict[str, Any]]: - awx_ldap_settings = {} - - for awx_ldap_setting in settings_registry.get_registered_settings(category_slug='ldap'): - key = awx_ldap_setting.removeprefix("AUTH_LDAP_") - value = getattr(settings, awx_ldap_setting, None) - awx_ldap_settings[key] = value - - grouped_settings = {} - - for key, value in awx_ldap_settings.items(): - match = re.search(r'(\d+)', key) - index = int(match.group()) if match else 0 - new_key = re.sub(r'\d+_', '', key) - - if index not in grouped_settings: - grouped_settings[index] = {} - - grouped_settings[index][new_key] = value - if new_key == "GROUP_TYPE" and value: - grouped_settings[index][new_key] = type(value).__name__ - - if new_key == "SERVER_URI" and value: - value = value.split(", ") - grouped_settings[index][new_key] = value - - if type(value).__name__ == "LDAPSearch": - data = [] - data.append(value.base_dn) - data.append("SCOPE_SUBTREE") - data.append(value.filterstr) - grouped_settings[index][new_key] = data - - return grouped_settings - def get_awx_saml_settings(self) -> dict[str, Any]: awx_saml_settings = {} for awx_saml_setting in settings_registry.get_registered_settings(category_slug='saml'): @@ -157,23 +107,6 @@ def handle(self, *args, **options): else: data.append({"SAML_missing_fields": saml_missing_fields}) - # dump LDAP settings - awx_ldap_group_settings = self.get_awx_ldap_settings() - for awx_ldap_name, awx_ldap_settings in awx_ldap_group_settings.items(): - awx_ldap_enabled, ldap_missing_fields = self.is_enabled(awx_ldap_settings, self.DAB_LDAP_AUTHENTICATOR_KEYS) - if awx_ldap_enabled: - data.append( - self.format_config_data( - awx_ldap_enabled, - awx_ldap_settings, - "ldap", - self.DAB_LDAP_AUTHENTICATOR_KEYS, - f"LDAP_{awx_ldap_name}", - ) - ) - else: - data.append({f"LDAP_{awx_ldap_name}_missing_fields": ldap_missing_fields}) - # write to file if requested if options["output_file"]: # Define the path for the output JSON file diff --git a/awx/main/middleware.py b/awx/main/middleware.py index 433ade596fe4..5b0b99c5e48a 100644 --- a/awx/main/middleware.py +++ b/awx/main/middleware.py @@ -93,7 +93,7 @@ def process_request(self, request): user = request.user if not user.pk: return - if not (user.profile.ldap_dn or user.social_auth.exists() or user.enterprise_auth.exists()): + if not (user.social_auth.exists() or user.enterprise_auth.exists()): logout(request) diff --git a/awx/main/migrations/0196_delete_profile.py b/awx/main/migrations/0196_delete_profile.py new file mode 100644 index 000000000000..a2179870bd81 --- /dev/null +++ b/awx/main/migrations/0196_delete_profile.py @@ -0,0 +1,16 @@ +# Generated by Django 4.2.10 on 2024-08-09 16:47 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0195_EE_permissions'), + ] + + operations = [ + migrations.DeleteModel( + name='Profile', + ), + ] diff --git a/awx/main/models/__init__.py b/awx/main/models/__init__.py index a799b077f30a..d371ec23927c 100644 --- a/awx/main/models/__init__.py +++ b/awx/main/models/__init__.py @@ -18,7 +18,7 @@ # AWX from awx.main.models.base import BaseModel, PrimordialModel, accepts_json, CLOUD_INVENTORY_SOURCES, VERBOSITY_CHOICES # noqa from awx.main.models.unified_jobs import UnifiedJob, UnifiedJobTemplate, StdoutMaxBytesExceeded # noqa -from awx.main.models.organization import Organization, Profile, Team, UserSessionMembership # noqa +from awx.main.models.organization import Organization, Team, UserSessionMembership # noqa from awx.main.models.credential import Credential, CredentialType, CredentialInputSource, ManagedCredentialType, build_safe_env # noqa from awx.main.models.projects import Project, ProjectUpdate # noqa from awx.main.models.receptor_address import ReceptorAddress # noqa @@ -292,7 +292,6 @@ def o_auth2_token_get_absolute_url(self, request=None): activity_stream_registrar.connect(AdHocCommand) # activity_stream_registrar.connect(JobHostSummary) # activity_stream_registrar.connect(JobEvent) -# activity_stream_registrar.connect(Profile) activity_stream_registrar.connect(Schedule) activity_stream_registrar.connect(NotificationTemplate) activity_stream_registrar.connect(Notification) diff --git a/awx/main/models/organization.py b/awx/main/models/organization.py index 939595ea9e9c..23ce7598a296 100644 --- a/awx/main/models/organization.py +++ b/awx/main/models/organization.py @@ -15,8 +15,8 @@ # AWX from awx.api.versioning import reverse -from awx.main.fields import AutoOneToOneField, ImplicitRoleField, OrderedManyToManyField -from awx.main.models.base import BaseModel, CommonModel, CommonModelNameNotUnique, CreatedModifiedModel, NotificationFieldsModel +from awx.main.fields import ImplicitRoleField, OrderedManyToManyField +from awx.main.models.base import BaseModel, CommonModel, CommonModelNameNotUnique, NotificationFieldsModel from awx.main.models.rbac import ( ROLE_SINGLETON_SYSTEM_ADMINISTRATOR, ROLE_SINGLETON_SYSTEM_AUDITOR, @@ -24,7 +24,7 @@ from awx.main.models.unified_jobs import UnifiedJob from awx.main.models.mixins import ResourceMixin, CustomVirtualEnvMixin, RelatedJobsMixin -__all__ = ['Organization', 'Team', 'Profile', 'UserSessionMembership'] +__all__ = ['Organization', 'Team', 'UserSessionMembership'] class Organization(CommonModel, NotificationFieldsModel, ResourceMixin, CustomVirtualEnvMixin, RelatedJobsMixin): @@ -167,22 +167,6 @@ def get_absolute_url(self, request=None): return reverse('api:team_detail', kwargs={'pk': self.pk}, request=request) -class Profile(CreatedModifiedModel): - """ - Profile model related to User object. Currently stores LDAP DN for users - loaded from LDAP. - """ - - class Meta: - app_label = 'main' - - user = AutoOneToOneField('auth.User', related_name='profile', editable=False, on_delete=models.CASCADE) - ldap_dn = models.CharField( - max_length=1024, - default='', - ) - - class UserSessionMembership(BaseModel): """ A lookup table for API session membership given user. Note, there is a diff --git a/awx/main/tests/functional/api/test_settings.py b/awx/main/tests/functional/api/test_settings.py index a84a6f7f6acd..6f6c2a5e09af 100644 --- a/awx/main/tests/functional/api/test_settings.py +++ b/awx/main/tests/functional/api/test_settings.py @@ -66,82 +66,6 @@ def test_awx_task_env_validity(get, patch, admin, value, expected): assert resp.data['AWX_TASK_ENV'] == dict() -@pytest.mark.django_db -def test_ldap_settings(get, put, patch, delete, admin): - url = reverse('api:setting_singleton_detail', kwargs={'category_slug': 'ldap'}) - get(url, user=admin, expect=200) - # The PUT below will fail at the moment because AUTH_LDAP_GROUP_TYPE - # defaults to None but cannot be set to None. - # put(url, user=admin, data=response.data, expect=200) - delete(url, user=admin, expect=204) - patch(url, user=admin, data={'AUTH_LDAP_SERVER_URI': ''}, expect=200) - patch(url, user=admin, data={'AUTH_LDAP_SERVER_URI': 'ldap.example.com'}, expect=400) - patch(url, user=admin, data={'AUTH_LDAP_SERVER_URI': 'ldap://ldap.example.com'}, expect=200) - patch(url, user=admin, data={'AUTH_LDAP_SERVER_URI': 'ldaps://ldap.example.com'}, expect=200) - patch(url, user=admin, data={'AUTH_LDAP_SERVER_URI': 'ldap://ldap.example.com:389'}, expect=200) - patch(url, user=admin, data={'AUTH_LDAP_SERVER_URI': 'ldaps://ldap.example.com:636'}, expect=200) - patch(url, user=admin, data={'AUTH_LDAP_SERVER_URI': 'ldap://ldap.example.com ldap://ldap2.example.com'}, expect=200) - patch(url, user=admin, data={'AUTH_LDAP_SERVER_URI': 'ldap://ldap.example.com,ldap://ldap2.example.com'}, expect=200) - patch(url, user=admin, data={'AUTH_LDAP_SERVER_URI': 'ldap://ldap.example.com, ldap://ldap2.example.com'}, expect=200) - patch(url, user=admin, data={'AUTH_LDAP_BIND_DN': 'cn=Manager,dc=example,dc=com'}, expect=200) - patch(url, user=admin, data={'AUTH_LDAP_BIND_DN': u'cn=暴力膜,dc=大新闻,dc=真的粉丝'}, expect=200) - - -@pytest.mark.django_db -@pytest.mark.parametrize( - 'value', - [ - None, - '', - 'INVALID', - 1, - [1], - ['INVALID'], - ], -) -def test_ldap_user_flags_by_group_invalid_dn(get, patch, admin, value): - url = reverse('api:setting_singleton_detail', kwargs={'category_slug': 'ldap'}) - patch(url, user=admin, data={'AUTH_LDAP_USER_FLAGS_BY_GROUP': {'is_superuser': value}}, expect=400) - - -@pytest.mark.django_db -def test_ldap_user_flags_by_group_string(get, patch, admin): - expected = 'CN=Admins,OU=Groups,DC=example,DC=com' - url = reverse('api:setting_singleton_detail', kwargs={'category_slug': 'ldap'}) - patch(url, user=admin, data={'AUTH_LDAP_USER_FLAGS_BY_GROUP': {'is_superuser': expected}}, expect=200) - resp = get(url, user=admin) - assert resp.data['AUTH_LDAP_USER_FLAGS_BY_GROUP']['is_superuser'] == [expected] - - -@pytest.mark.django_db -def test_ldap_user_flags_by_group_list(get, patch, admin): - expected = ['CN=Admins,OU=Groups,DC=example,DC=com', 'CN=Superadmins,OU=Groups,DC=example,DC=com'] - url = reverse('api:setting_singleton_detail', kwargs={'category_slug': 'ldap'}) - patch(url, user=admin, data={'AUTH_LDAP_USER_FLAGS_BY_GROUP': {'is_superuser': expected}}, expect=200) - resp = get(url, user=admin) - assert resp.data['AUTH_LDAP_USER_FLAGS_BY_GROUP']['is_superuser'] == expected - - -@pytest.mark.parametrize( - 'setting', - [ - 'AUTH_LDAP_USER_DN_TEMPLATE', - 'AUTH_LDAP_REQUIRE_GROUP', - 'AUTH_LDAP_DENY_GROUP', - ], -) -@pytest.mark.django_db -def test_empty_ldap_dn(get, put, patch, delete, admin, setting): - url = reverse('api:setting_singleton_detail', kwargs={'category_slug': 'ldap'}) - patch(url, user=admin, data={setting: ''}, expect=200) - resp = get(url, user=admin, expect=200) - assert resp.data[setting] is None - - patch(url, user=admin, data={setting: None}, expect=200) - resp = get(url, user=admin, expect=200) - assert resp.data[setting] is None - - @pytest.mark.django_db def test_radius_settings(get, put, patch, delete, admin, settings): url = reverse('api:setting_singleton_detail', kwargs={'category_slug': 'radius'}) diff --git a/awx/main/tests/functional/test_ldap.py b/awx/main/tests/functional/test_ldap.py deleted file mode 100644 index 2467ff52e38f..000000000000 --- a/awx/main/tests/functional/test_ldap.py +++ /dev/null @@ -1,103 +0,0 @@ -import ldap -import ldif -import pytest -import os -from mockldap import MockLdap - -from awx.api.versioning import reverse - - -@pytest.fixture -def ldap_generator(): - def fn(fname, host='localhost'): - fh = open(os.path.join(os.path.dirname(os.path.realpath(__file__)), fname), 'rb') - ctrl = ldif.LDIFRecordList(fh) - ctrl.parse() - - directory = dict(ctrl.all_records) - - mockldap = MockLdap(directory) - - mockldap.start() - mockldap['ldap://{}/'.format(host)] - - conn = ldap.initialize('ldap://{}/'.format(host)) - - return conn - # mockldap.stop() - - return fn - - -@pytest.fixture -def ldap_settings_generator(): - def fn(prefix='', dc='ansible', host='ldap.ansible.com'): - prefix = '_{}'.format(prefix) if prefix else '' - - data = { - 'AUTH_LDAP_SERVER_URI': 'ldap://{}'.format(host), - 'AUTH_LDAP_BIND_DN': 'cn=eng_user1,ou=people,dc={},dc=com'.format(dc), - 'AUTH_LDAP_BIND_PASSWORD': 'password', - "AUTH_LDAP_USER_SEARCH": ["ou=people,dc={},dc=com".format(dc), "SCOPE_SUBTREE", "(cn=%(user)s)"], - "AUTH_LDAP_TEAM_MAP": { - "LDAP Sales": {"organization": "LDAP Organization", "users": "cn=sales,ou=groups,dc={},dc=com".format(dc), "remove": True}, - "LDAP IT": {"organization": "LDAP Organization", "users": "cn=it,ou=groups,dc={},dc=com".format(dc), "remove": True}, - "LDAP Engineering": {"organization": "LDAP Organization", "users": "cn=engineering,ou=groups,dc={},dc=com".format(dc), "remove": True}, - }, - "AUTH_LDAP_REQUIRE_GROUP": None, - "AUTH_LDAP_USER_ATTR_MAP": {"first_name": "givenName", "last_name": "sn", "email": "mail"}, - "AUTH_LDAP_GROUP_SEARCH": ["dc={},dc=com".format(dc), "SCOPE_SUBTREE", "(objectClass=groupOfNames)"], - "AUTH_LDAP_USER_FLAGS_BY_GROUP": {"is_superuser": "cn=superusers,ou=groups,dc={},dc=com".format(dc)}, - "AUTH_LDAP_ORGANIZATION_MAP": { - "LDAP Organization": { - "admins": "cn=engineering_admins,ou=groups,dc={},dc=com".format(dc), - "remove_admins": False, - "users": [ - "cn=engineering,ou=groups,dc={},dc=com".format(dc), - "cn=sales,ou=groups,dc={},dc=com".format(dc), - "cn=it,ou=groups,dc={},dc=com".format(dc), - ], - "remove_users": False, - } - }, - } - - if prefix: - data_new = dict() - for k, v in data.items(): - k_new = k.replace('AUTH_LDAP', 'AUTH_LDAP{}'.format(prefix)) - data_new[k_new] = v - else: - data_new = data - - return data_new - - return fn - - -# Note: mockldap isn't fully featured. Fancy queries aren't fully baked. -# However, objects returned are solid so they should flow through django ldap middleware nicely. -@pytest.mark.skip(reason="Needs Update - CA") -@pytest.mark.django_db -def test_login(ldap_generator, patch, post, admin, ldap_settings_generator): - auth_url = reverse('api:auth_token_view') - ldap_settings_url = reverse('api:setting_singleton_detail', kwargs={'category_slug': 'ldap'}) - - # Generate mock ldap servers and init with ldap data - ldap_generator("../data/ldap_example.ldif", "ldap.example.com") - ldap_generator("../data/ldap_redhat.ldif", "ldap.redhat.com") - ldap_generator("../data/ldap_ansible.ldif", "ldap.ansible.com") - - ldap_settings_example = ldap_settings_generator(dc='example') - ldap_settings_ansible = ldap_settings_generator(prefix='1', dc='ansible') - ldap_settings_redhat = ldap_settings_generator(prefix='2', dc='redhat') - - # eng_user1 exists in ansible and redhat but not example - patch(ldap_settings_url, user=admin, data=ldap_settings_example, expect=200) - - post(auth_url, data={'username': 'eng_user1', 'password': 'password'}, expect=400) - - patch(ldap_settings_url, user=admin, data=ldap_settings_ansible, expect=200) - patch(ldap_settings_url, user=admin, data=ldap_settings_redhat, expect=200) - - post(auth_url, data={'username': 'eng_user1', 'password': 'password'}, expect=200) diff --git a/awx/main/tests/unit/commands/test_dump_auth_config.py b/awx/main/tests/unit/commands/test_dump_auth_config.py index 48024ff5e425..3b1c6e67d03d 100644 --- a/awx/main/tests/unit/commands/test_dump_auth_config.py +++ b/awx/main/tests/unit/commands/test_dump_auth_config.py @@ -28,21 +28,6 @@ } }, "SOCIAL_AUTH_SAML_CALLBACK_URL": "CALLBACK_URL", - "AUTH_LDAP_1_SERVER_URI": "SERVER_URI", - "AUTH_LDAP_1_BIND_DN": "BIND_DN", - "AUTH_LDAP_1_BIND_PASSWORD": "BIND_PASSWORD", - "AUTH_LDAP_1_GROUP_SEARCH": ["GROUP_SEARCH"], - "AUTH_LDAP_1_GROUP_TYPE": "string object", - "AUTH_LDAP_1_GROUP_TYPE_PARAMS": {"member_attr": "member", "name_attr": "cn"}, - "AUTH_LDAP_1_USER_DN_TEMPLATE": "USER_DN_TEMPLATE", - "AUTH_LDAP_1_USER_SEARCH": ["USER_SEARCH"], - "AUTH_LDAP_1_USER_ATTR_MAP": { - "email": "email", - "last_name": "last_name", - "first_name": "first_name", - }, - "AUTH_LDAP_1_CONNECTION_OPTIONS": {}, - "AUTH_LDAP_1_START_TLS": None, } @@ -93,27 +78,6 @@ def setUp(self): "IDP_ATTR_USER_PERMANENT_ID": "name_id", }, }, - { - "type": "ansible_base.authentication.authenticator_plugins.ldap", - "name": "LDAP_1", - "enabled": True, - "create_objects": True, - "users_unique": False, - "remove_users": True, - "configuration": { - "SERVER_URI": ["SERVER_URI"], - "BIND_DN": "BIND_DN", - "BIND_PASSWORD": "BIND_PASSWORD", - "CONNECTION_OPTIONS": {}, - "GROUP_TYPE": "str", - "GROUP_TYPE_PARAMS": {"member_attr": "member", "name_attr": "cn"}, - "GROUP_SEARCH": ["GROUP_SEARCH"], - "START_TLS": None, - "USER_DN_TEMPLATE": "USER_DN_TEMPLATE", - "USER_ATTR_MAP": {"email": "email", "last_name": "last_name", "first_name": "first_name"}, - "USER_SEARCH": ["USER_SEARCH"], - }, - }, ] def test_json_returned_from_cmd(self): @@ -123,10 +87,3 @@ def test_json_returned_from_cmd(self): # check configured SAML return assert cmmd_output[0] == self.expected_config[0] - - # check configured LDAP return - assert cmmd_output[2] == self.expected_config[1] - - # check unconfigured LDAP return - assert "LDAP_0_missing_fields" in cmmd_output[1] - assert cmmd_output[1]["LDAP_0_missing_fields"] == ['SERVER_URI', 'GROUP_TYPE', 'GROUP_TYPE_PARAMS', 'USER_DN_TEMPLATE', 'USER_ATTR_MAP'] diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index d51818d7222e..a1fa3ce479a7 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -9,8 +9,6 @@ import socket from datetime import timedelta -# python-ldap -import ldap from split_settings.tools import include @@ -389,12 +387,6 @@ } AUTHENTICATION_BACKENDS = ( - 'awx.sso.backends.LDAPBackend', - 'awx.sso.backends.LDAPBackend1', - 'awx.sso.backends.LDAPBackend2', - 'awx.sso.backends.LDAPBackend3', - 'awx.sso.backends.LDAPBackend4', - 'awx.sso.backends.LDAPBackend5', 'awx.sso.backends.RADIUSBackend', 'awx.sso.backends.TACACSPlusBackend', 'social_core.backends.google.GoogleOAuth2', @@ -420,14 +412,6 @@ OAUTH2_PROVIDER = {'ACCESS_TOKEN_EXPIRE_SECONDS': 31536000000, 'AUTHORIZATION_CODE_EXPIRE_SECONDS': 600, 'REFRESH_TOKEN_EXPIRE_SECONDS': 2628000} ALLOW_OAUTH2_FOR_EXTERNAL_USERS = False -# LDAP server (default to None to skip using LDAP authentication). -# Note: This setting may be overridden by database settings. -AUTH_LDAP_SERVER_URI = None - -# Disable LDAP referrals by default (to prevent certain LDAP queries from -# hanging with AD). -# Note: This setting may be overridden by database settings. -AUTH_LDAP_CONNECTION_OPTIONS = {ldap.OPT_REFERRALS: 0, ldap.OPT_NETWORK_TIMEOUT: 30} # Radius server settings (default to empty string to skip using Radius auth). # Note: These settings may be overridden by database settings. @@ -927,7 +911,6 @@ 'awx.analytics.broadcast_websocket': {'handlers': ['console', 'file', 'wsrelay', 'external_logger'], 'level': 'INFO', 'propagate': False}, 'awx.analytics.performance': {'handlers': ['console', 'file', 'tower_warnings', 'external_logger'], 'level': 'DEBUG', 'propagate': False}, 'awx.analytics.job_lifecycle': {'handlers': ['console', 'job_lifecycle'], 'level': 'DEBUG', 'propagate': False}, - 'django_auth_ldap': {'handlers': ['console', 'file', 'tower_warnings'], 'level': 'DEBUG'}, 'social': {'handlers': ['console', 'file', 'tower_warnings'], 'level': 'DEBUG'}, 'system_tracking_migrations': {'handlers': ['console', 'file', 'tower_warnings'], 'level': 'DEBUG'}, 'rbac_migrations': {'handlers': ['console', 'file', 'tower_warnings'], 'level': 'DEBUG'}, diff --git a/awx/sso/backends.py b/awx/sso/backends.py index 572afc3ef04b..3b60cb223e67 100644 --- a/awx/sso/backends.py +++ b/awx/sso/backends.py @@ -2,26 +2,14 @@ # All Rights Reserved. # Python -from collections import OrderedDict import logging -import uuid - -import ldap # Django -from django.dispatch import receiver from django.contrib.auth.models import User from django.conf import settings as django_settings -from django.core.signals import setting_changed from django.utils.encoding import force_str from django.http import HttpResponse -# django-auth-ldap -from django_auth_ldap.backend import LDAPSettings as BaseLDAPSettings -from django_auth_ldap.backend import LDAPBackend as BaseLDAPBackend -from django_auth_ldap.backend import populate_user -from django.core.exceptions import ImproperlyConfigured - # radiusauth from radiusauth.backends import RADIUSBackend as BaseRADIUSBackend @@ -35,143 +23,10 @@ # Ansible Tower from awx.sso.models import UserEnterpriseAuth -from awx.sso.common import create_org_and_teams, reconcile_users_org_team_mappings logger = logging.getLogger('awx.sso.backends') -class LDAPSettings(BaseLDAPSettings): - defaults = dict(list(BaseLDAPSettings.defaults.items()) + list({'ORGANIZATION_MAP': {}, 'TEAM_MAP': {}, 'GROUP_TYPE_PARAMS': {}}.items())) - - def __init__(self, prefix='AUTH_LDAP_', defaults={}): - super(LDAPSettings, self).__init__(prefix, defaults) - - # If a DB-backed setting is specified that wipes out the - # OPT_NETWORK_TIMEOUT, fall back to a sane default - if ldap.OPT_NETWORK_TIMEOUT not in getattr(self, 'CONNECTION_OPTIONS', {}): - options = getattr(self, 'CONNECTION_OPTIONS', {}) - options[ldap.OPT_NETWORK_TIMEOUT] = 30 - self.CONNECTION_OPTIONS = options - - # when specifying `.set_option()` calls for TLS in python-ldap, the - # *order* in which you invoke them *matters*, particularly in Python3, - # where dictionary insertion order is persisted - # - # specifically, it is *critical* that `ldap.OPT_X_TLS_NEWCTX` be set *last* - # this manual sorting puts `OPT_X_TLS_NEWCTX` *after* other TLS-related - # options - # - # see: https://github.com/python-ldap/python-ldap/issues/55 - newctx_option = self.CONNECTION_OPTIONS.pop(ldap.OPT_X_TLS_NEWCTX, None) - self.CONNECTION_OPTIONS = OrderedDict(self.CONNECTION_OPTIONS) - if newctx_option is not None: - self.CONNECTION_OPTIONS[ldap.OPT_X_TLS_NEWCTX] = newctx_option - - -class LDAPBackend(BaseLDAPBackend): - """ - Custom LDAP backend for AWX. - """ - - settings_prefix = 'AUTH_LDAP_' - - def __init__(self, *args, **kwargs): - self._dispatch_uid = uuid.uuid4() - super(LDAPBackend, self).__init__(*args, **kwargs) - setting_changed.connect(self._on_setting_changed, dispatch_uid=self._dispatch_uid) - - def _on_setting_changed(self, sender, **kwargs): - # If any AUTH_LDAP_* setting changes, force settings to be reloaded for - # this backend instance. - if kwargs.get('setting', '').startswith(self.settings_prefix): - self._settings = None - - def _get_settings(self): - if self._settings is None: - self._settings = LDAPSettings(self.settings_prefix) - return self._settings - - def _set_settings(self, settings): - self._settings = settings - - settings = property(_get_settings, _set_settings) - - def authenticate(self, request, username, password): - if self.settings.START_TLS and ldap.OPT_X_TLS_REQUIRE_CERT in self.settings.CONNECTION_OPTIONS: - # with python-ldap, if you want to set connection-specific TLS - # parameters, you must also specify OPT_X_TLS_NEWCTX = 0 - # see: https://stackoverflow.com/a/29722445 - # see: https://stackoverflow.com/a/38136255 - self.settings.CONNECTION_OPTIONS[ldap.OPT_X_TLS_NEWCTX] = 0 - - if not self.settings.SERVER_URI: - return None - try: - user = User.objects.get(username=username) - if user and (not user.profile or not user.profile.ldap_dn): - return None - except User.DoesNotExist: - pass - - try: - for setting_name, type_ in [('GROUP_SEARCH', 'LDAPSearch'), ('GROUP_TYPE', 'LDAPGroupType')]: - if getattr(self.settings, setting_name) is None: - raise ImproperlyConfigured("{} must be an {} instance.".format(setting_name, type_)) - ldap_user = super(LDAPBackend, self).authenticate(request, username, password) - # If we have an LDAP user and that user we found has an ldap_user internal object and that object has a bound connection - # Then we can try and force an unbind to close the sticky connection - if ldap_user and ldap_user.ldap_user and ldap_user.ldap_user._connection_bound: - logger.debug("Forcing LDAP connection to close") - try: - ldap_user.ldap_user._connection.unbind_s() - ldap_user.ldap_user._connection_bound = False - except Exception: - logger.exception(f"Got unexpected LDAP exception when forcing LDAP disconnect for user {ldap_user}, login will still proceed") - return ldap_user - except Exception: - logger.exception("Encountered an error authenticating to LDAP") - return None - - def get_user(self, user_id): - if not self.settings.SERVER_URI: - return None - return super(LDAPBackend, self).get_user(user_id) - - # Disable any LDAP based authorization / permissions checking. - - def has_perm(self, user, perm, obj=None): - return False - - def has_module_perms(self, user, app_label): - return False - - def get_all_permissions(self, user, obj=None): - return set() - - def get_group_permissions(self, user, obj=None): - return set() - - -class LDAPBackend1(LDAPBackend): - settings_prefix = 'AUTH_LDAP_1_' - - -class LDAPBackend2(LDAPBackend): - settings_prefix = 'AUTH_LDAP_2_' - - -class LDAPBackend3(LDAPBackend): - settings_prefix = 'AUTH_LDAP_3_' - - -class LDAPBackend4(LDAPBackend): - settings_prefix = 'AUTH_LDAP_4_' - - -class LDAPBackend5(LDAPBackend): - settings_prefix = 'AUTH_LDAP_5_' - - def _decorate_enterprise_user(user, provider): user.set_unusable_password() user.save() @@ -348,122 +203,3 @@ def get_user(self, user_id): ): return None return super(SAMLAuth, self).get_user(user_id) - - -def _update_m2m_from_groups(ldap_user, opts, remove=True): - """ - Hepler function to evaluate the LDAP team/org options to determine if LDAP user should - be a member of the team/org based on their ldap group dns. - - Returns: - True - User should be added - False - User should be removed - None - Users membership should not be changed - """ - if opts is None: - return None - elif not opts: - pass - elif isinstance(opts, bool) and opts is True: - return True - else: - if isinstance(opts, str): - opts = [opts] - # If any of the users groups matches any of the list options - for group_dn in opts: - if not isinstance(group_dn, str): - continue - if ldap_user._get_groups().is_member_of(group_dn): - return True - if remove: - return False - return None - - -@receiver(populate_user, dispatch_uid='populate-ldap-user') -def on_populate_user(sender, **kwargs): - """ - Handle signal from LDAP backend to populate the user object. Update user - organization/team memberships according to their LDAP groups. - """ - user = kwargs['user'] - ldap_user = kwargs['ldap_user'] - backend = ldap_user.backend - - # Boolean to determine if we should force an user update - # to avoid duplicate SQL update statements - force_user_update = False - - # Prefetch user's groups to prevent LDAP queries for each org/team when - # checking membership. - ldap_user._get_groups().get_group_dns() - - # If the LDAP user has a first or last name > $maxlen chars, truncate it - for field in ('first_name', 'last_name'): - max_len = User._meta.get_field(field).max_length - field_len = len(getattr(user, field)) - if field_len > max_len: - setattr(user, field, getattr(user, field)[:max_len]) - force_user_update = True - logger.warning('LDAP user {} has {} > max {} characters'.format(user.username, field, max_len)) - - org_map = getattr(backend.settings, 'ORGANIZATION_MAP', {}) - team_map_settings = getattr(backend.settings, 'TEAM_MAP', {}) - orgs_list = list(org_map.keys()) - team_map = {} - for team_name, team_opts in team_map_settings.items(): - if not team_opts.get('organization', None): - # You can't save the LDAP config in the UI w/o an org (or '' or null as the org) so if we somehow got this condition its an error - logger.error("Team named {} in LDAP team map settings is invalid due to missing organization".format(team_name)) - continue - team_map[team_name] = team_opts['organization'] - - create_org_and_teams(orgs_list, team_map, 'LDAP') - - # Compute in memory what the state is of the different LDAP orgs - org_roles_and_ldap_attributes = {'admin_role': 'admins', 'auditor_role': 'auditors', 'member_role': 'users'} - desired_org_states = {} - for org_name, org_opts in org_map.items(): - remove = bool(org_opts.get('remove', True)) - desired_org_states[org_name] = {} - for org_role_name in org_roles_and_ldap_attributes.keys(): - ldap_name = org_roles_and_ldap_attributes[org_role_name] - opts = org_opts.get(ldap_name, None) - remove = bool(org_opts.get('remove_{}'.format(ldap_name), remove)) - desired_org_states[org_name][org_role_name] = _update_m2m_from_groups(ldap_user, opts, remove) - - # If everything returned None (because there was no configuration) we can remove this org from our map - # This will prevent us from loading the org in the next query - if all(desired_org_states[org_name][org_role_name] is None for org_role_name in org_roles_and_ldap_attributes.keys()): - del desired_org_states[org_name] - - # Compute in memory what the state is of the different LDAP teams - desired_team_states = {} - for team_name, team_opts in team_map_settings.items(): - if 'organization' not in team_opts: - continue - users_opts = team_opts.get('users', None) - remove = bool(team_opts.get('remove', True)) - state = _update_m2m_from_groups(ldap_user, users_opts, remove) - if state is not None: - organization = team_opts['organization'] - if organization not in desired_team_states: - desired_team_states[organization] = {} - desired_team_states[organization][team_name] = {'member_role': state} - - # Check if user.profile is available, otherwise force user.save() - try: - _ = user.profile - except ValueError: - force_user_update = True - finally: - if force_user_update: - user.save() - - # Update user profile to store LDAP DN. - profile = user.profile - if profile.ldap_dn != ldap_user.dn: - profile.ldap_dn = ldap_user.dn - profile.save() - - reconcile_users_org_team_mappings(user, desired_org_states, desired_team_states, 'LDAP') diff --git a/awx/sso/common.py b/awx/sso/common.py index 99abc51d5a03..b57506f67dac 100644 --- a/awx/sso/common.py +++ b/awx/sso/common.py @@ -113,7 +113,7 @@ def create_org_and_teams(org_list, team_map, adapter, can_create=True): logger.debug(f"Adapter {adapter} is not allowed to create orgs/teams") return - # Get all of the IDs and names of orgs in the DB and create any new org defined in LDAP that does not exist in the DB + # Get all of the IDs and names of orgs in the DB and create any new org defined in org_list that does not exist in the DB existing_orgs = get_orgs_by_ids() # Parse through orgs and teams provided and create a list of unique items we care about creating @@ -174,18 +174,6 @@ def get_or_create_org_with_default_galaxy_cred(**kwargs): def get_external_account(user): account_type = None - # Previously this method also checked for active configuration which meant that if a user logged in from LDAP - # and then LDAP was no longer configured it would "convert" the user from an LDAP account_type to none. - # This did have one benefit that if a login type was removed intentionally the user could be given a username password. - # But it had a limitation that the user would have to have an active session (or an admin would have to go set a temp password). - # It also lead to the side affect that if LDAP was ever reconfigured the user would convert back to LDAP but still have a local password. - # That local password could then be used to bypass LDAP authentication. - try: - if user.pk and user.profile.ldap_dn and not user.has_usable_password(): - account_type = "ldap" - except AttributeError: - pass - if user.social_auth.all(): account_type = "social" @@ -198,9 +186,8 @@ def get_external_account(user): def is_remote_auth_enabled(): from django.conf import settings - # Append LDAP, Radius, TACACS+ and SAML options + # Append Radius, TACACS+ and SAML options settings_that_turn_on_remote_auth = [ - 'AUTH_LDAP_SERVER_URI', 'SOCIAL_AUTH_SAML_ENABLED_IDPS', 'RADIUS_SERVER', 'TACACSPLUS_HOST', diff --git a/awx/sso/conf.py b/awx/sso/conf.py index 03640fccd8ae..332815deb6e7 100644 --- a/awx/sso/conf.py +++ b/awx/sso/conf.py @@ -14,18 +14,6 @@ from awx.conf import register, register_validate, fields from awx.sso.fields import ( AuthenticationBackendsField, - LDAPConnectionOptionsField, - LDAPDNField, - LDAPDNWithUserField, - LDAPGroupTypeField, - LDAPGroupTypeParamsField, - LDAPOrganizationMapField, - LDAPSearchField, - LDAPSearchUnionField, - LDAPServerURIField, - LDAPTeamMapField, - LDAPUserAttrMapField, - LDAPUserFlagsField, SAMLContactField, SAMLEnabledIdPsField, SAMLOrgAttrField, @@ -37,7 +25,7 @@ SocialTeamMapField, ) from awx.main.validators import validate_private_key, validate_certificate -from awx.sso.validators import validate_ldap_bind_dn, validate_tacacsplus_disallow_nonascii # noqa +from awx.sso.validators import validate_tacacsplus_disallow_nonascii # noqa class SocialAuthCallbackURL(object): @@ -159,297 +147,6 @@ def __call__(self): category_slug='authentication', ) - ############################################################################### - # LDAP AUTHENTICATION SETTINGS - ############################################################################### - - def _register_ldap(append=None): - append_str = '_{}'.format(append) if append else '' - - register( - 'AUTH_LDAP{}_SERVER_URI'.format(append_str), - field_class=LDAPServerURIField, - allow_blank=True, - default='', - label=_('LDAP Server URI'), - help_text=_( - 'URI to connect to LDAP server, such as "ldap://ldap.example.com:389" ' - '(non-SSL) or "ldaps://ldap.example.com:636" (SSL). Multiple LDAP ' - 'servers may be specified by separating with spaces or commas. LDAP ' - 'authentication is disabled if this parameter is empty.' - ), - category=_('LDAP'), - category_slug='ldap', - placeholder='ldaps://ldap.example.com:636', - ) - - register( - 'AUTH_LDAP{}_BIND_DN'.format(append_str), - field_class=fields.CharField, - allow_blank=True, - default='', - validators=[validate_ldap_bind_dn], - label=_('LDAP Bind DN'), - help_text=_( - 'DN (Distinguished Name) of user to bind for all search queries. This' - ' is the system user account we will use to login to query LDAP for other' - ' user information. Refer to the documentation for example syntax.' - ), - category=_('LDAP'), - category_slug='ldap', - ) - - register( - 'AUTH_LDAP{}_BIND_PASSWORD'.format(append_str), - field_class=fields.CharField, - allow_blank=True, - default='', - label=_('LDAP Bind Password'), - help_text=_('Password used to bind LDAP user account.'), - category=_('LDAP'), - category_slug='ldap', - encrypted=True, - ) - - register( - 'AUTH_LDAP{}_START_TLS'.format(append_str), - field_class=fields.BooleanField, - default=False, - label=_('LDAP Start TLS'), - help_text=_('Whether to enable TLS when the LDAP connection is not using SSL.'), - category=_('LDAP'), - category_slug='ldap', - ) - - register( - 'AUTH_LDAP{}_CONNECTION_OPTIONS'.format(append_str), - field_class=LDAPConnectionOptionsField, - default={'OPT_REFERRALS': 0, 'OPT_NETWORK_TIMEOUT': 30}, - label=_('LDAP Connection Options'), - help_text=_( - 'Additional options to set for the LDAP connection. LDAP ' - 'referrals are disabled by default (to prevent certain LDAP ' - 'queries from hanging with AD). Option names should be strings ' - '(e.g. "OPT_REFERRALS"). Refer to ' - 'https://www.python-ldap.org/doc/html/ldap.html#options for ' - 'possible options and values that can be set.' - ), - category=_('LDAP'), - category_slug='ldap', - placeholder=collections.OrderedDict([('OPT_REFERRALS', 0), ('OPT_NETWORK_TIMEOUT', 30)]), - ) - - register( - 'AUTH_LDAP{}_USER_SEARCH'.format(append_str), - field_class=LDAPSearchUnionField, - default=[], - label=_('LDAP User Search'), - help_text=_( - 'LDAP search query to find users. Any user that matches the given ' - 'pattern will be able to login to the service. The user should also be ' - 'mapped into an organization (as defined in the ' - 'AUTH_LDAP_ORGANIZATION_MAP setting). If multiple search queries ' - 'need to be supported use of "LDAPUnion" is possible. See ' - 'the documentation for details.' - ), - category=_('LDAP'), - category_slug='ldap', - placeholder=('OU=Users,DC=example,DC=com', 'SCOPE_SUBTREE', '(sAMAccountName=%(user)s)'), - ) - - register( - 'AUTH_LDAP{}_USER_DN_TEMPLATE'.format(append_str), - field_class=LDAPDNWithUserField, - allow_blank=True, - allow_null=True, - default=None, - label=_('LDAP User DN Template'), - help_text=_( - 'Alternative to user search, if user DNs are all of the same ' - 'format. This approach is more efficient for user lookups than ' - 'searching if it is usable in your organizational environment. If ' - 'this setting has a value it will be used instead of ' - 'AUTH_LDAP_USER_SEARCH.' - ), - category=_('LDAP'), - category_slug='ldap', - placeholder='uid=%(user)s,OU=Users,DC=example,DC=com', - ) - - register( - 'AUTH_LDAP{}_USER_ATTR_MAP'.format(append_str), - field_class=LDAPUserAttrMapField, - default={}, - label=_('LDAP User Attribute Map'), - help_text=_( - 'Mapping of LDAP user schema to API user attributes. The default' - ' setting is valid for ActiveDirectory but users with other LDAP' - ' configurations may need to change the values. Refer to the' - ' documentation for additional details.' - ), - category=_('LDAP'), - category_slug='ldap', - placeholder=collections.OrderedDict([('first_name', 'givenName'), ('last_name', 'sn'), ('email', 'mail')]), - ) - - register( - 'AUTH_LDAP{}_GROUP_SEARCH'.format(append_str), - field_class=LDAPSearchField, - default=[], - label=_('LDAP Group Search'), - help_text=_( - 'Users are mapped to organizations based on their membership in LDAP' - ' groups. This setting defines the LDAP search query to find groups. ' - 'Unlike the user search, group search does not support LDAPSearchUnion.' - ), - category=_('LDAP'), - category_slug='ldap', - placeholder=('DC=example,DC=com', 'SCOPE_SUBTREE', '(objectClass=group)'), - ) - - register( - 'AUTH_LDAP{}_GROUP_TYPE'.format(append_str), - field_class=LDAPGroupTypeField, - label=_('LDAP Group Type'), - help_text=_( - 'The group type may need to be changed based on the type of the ' - 'LDAP server. Values are listed at: ' - 'https://django-auth-ldap.readthedocs.io/en/stable/groups.html#types-of-groups' - ), - category=_('LDAP'), - category_slug='ldap', - default='MemberDNGroupType', - depends_on=['AUTH_LDAP{}_GROUP_TYPE_PARAMS'.format(append_str)], - ) - - register( - 'AUTH_LDAP{}_GROUP_TYPE_PARAMS'.format(append_str), - field_class=LDAPGroupTypeParamsField, - label=_('LDAP Group Type Parameters'), - help_text=_('Key value parameters to send the chosen group type init method.'), - category=_('LDAP'), - category_slug='ldap', - default=collections.OrderedDict([('member_attr', 'member'), ('name_attr', 'cn')]), - placeholder=collections.OrderedDict([('ldap_group_user_attr', 'legacyuid'), ('member_attr', 'member'), ('name_attr', 'cn')]), - depends_on=['AUTH_LDAP{}_GROUP_TYPE'.format(append_str)], - ) - - register( - 'AUTH_LDAP{}_REQUIRE_GROUP'.format(append_str), - field_class=LDAPDNField, - allow_blank=True, - allow_null=True, - default=None, - label=_('LDAP Require Group'), - help_text=_( - 'Group DN required to login. If specified, user must be a member ' - 'of this group to login via LDAP. If not set, everyone in LDAP ' - 'that matches the user search will be able to login to the service. ' - 'Only one require group is supported.' - ), - category=_('LDAP'), - category_slug='ldap', - placeholder='CN=Service Users,OU=Users,DC=example,DC=com', - ) - - register( - 'AUTH_LDAP{}_DENY_GROUP'.format(append_str), - field_class=LDAPDNField, - allow_blank=True, - allow_null=True, - default=None, - label=_('LDAP Deny Group'), - help_text=_( - 'Group DN denied from login. If specified, user will not be allowed to login if a member of this group. Only one deny group is supported.' - ), - category=_('LDAP'), - category_slug='ldap', - placeholder='CN=Disabled Users,OU=Users,DC=example,DC=com', - ) - - register( - 'AUTH_LDAP{}_USER_FLAGS_BY_GROUP'.format(append_str), - field_class=LDAPUserFlagsField, - default={}, - label=_('LDAP User Flags By Group'), - help_text=_( - 'Retrieve users from a given group. At this time, superuser and system' - ' auditors are the only groups supported. Refer to the' - ' documentation for more detail.' - ), - category=_('LDAP'), - category_slug='ldap', - placeholder=collections.OrderedDict( - [('is_superuser', 'CN=Domain Admins,CN=Users,DC=example,DC=com'), ('is_system_auditor', 'CN=Domain Auditors,CN=Users,DC=example,DC=com')] - ), - ) - - register( - 'AUTH_LDAP{}_ORGANIZATION_MAP'.format(append_str), - field_class=LDAPOrganizationMapField, - default={}, - label=_('LDAP Organization Map'), - help_text=_( - 'Mapping between organization admins/users and LDAP groups. This ' - 'controls which users are placed into which organizations ' - 'relative to their LDAP group memberships. Configuration details ' - 'are available in the documentation.' - ), - category=_('LDAP'), - category_slug='ldap', - placeholder=collections.OrderedDict( - [ - ( - 'Test Org', - collections.OrderedDict( - [ - ('admins', 'CN=Domain Admins,CN=Users,DC=example,DC=com'), - ('auditors', 'CN=Domain Auditors,CN=Users,DC=example,DC=com'), - ('users', ['CN=Domain Users,CN=Users,DC=example,DC=com']), - ('remove_users', True), - ('remove_admins', True), - ] - ), - ), - ( - 'Test Org 2', - collections.OrderedDict( - [('admins', 'CN=Administrators,CN=Builtin,DC=example,DC=com'), ('users', True), ('remove_users', True), ('remove_admins', True)] - ), - ), - ] - ), - ) - - register( - 'AUTH_LDAP{}_TEAM_MAP'.format(append_str), - field_class=LDAPTeamMapField, - default={}, - label=_('LDAP Team Map'), - help_text=_('Mapping between team members (users) and LDAP groups. Configuration details are available in the documentation.'), - category=_('LDAP'), - category_slug='ldap', - placeholder=collections.OrderedDict( - [ - ( - 'My Team', - collections.OrderedDict([('organization', 'Test Org'), ('users', ['CN=Domain Users,CN=Users,DC=example,DC=com']), ('remove', True)]), - ), - ( - 'Other Team', - collections.OrderedDict([('organization', 'Test Org 2'), ('users', 'CN=Other Users,CN=Users,DC=example,DC=com'), ('remove', False)]), - ), - ] - ), - ) - - _register_ldap() - _register_ldap('1') - _register_ldap('2') - _register_ldap('3') - _register_ldap('4') - _register_ldap('5') - ############################################################################### # RADIUS AUTHENTICATION SETTINGS ############################################################################### diff --git a/awx/sso/fields.py b/awx/sso/fields.py index a81cb1cf34d4..d0ee30316992 100644 --- a/awx/sso/fields.py +++ b/awx/sso/fields.py @@ -1,39 +1,20 @@ import collections import copy -import inspect import json import re import six -# Python LDAP -import ldap -import awx - # Django from django.utils.translation import gettext_lazy as _ -# Django Auth LDAP -import django_auth_ldap.config -from django_auth_ldap.config import LDAPSearch, LDAPSearchUnion - from rest_framework.exceptions import ValidationError from rest_framework.fields import empty, Field, SkipField -# This must be imported so get_subclasses picks it up -from awx.sso.ldap_group_types import PosixUIDGroupType # noqa - # AWX from awx.conf import fields from awx.main.validators import validate_certificate -from awx.sso.validators import ( # noqa - validate_ldap_dn, - validate_ldap_bind_dn, - validate_ldap_dn_with_user, - validate_ldap_filter, - validate_ldap_filter_with_user, - validate_tacacsplus_disallow_nonascii, -) +from awx.sso.validators import validate_tacacsplus_disallow_nonascii # noqa def get_subclasses(cls): @@ -43,18 +24,6 @@ def get_subclasses(cls): yield subclass -def find_class_in_modules(class_name): - """ - Used to find ldap subclasses by string - """ - module_search_space = [django_auth_ldap.config, awx.sso.ldap_group_types] - for m in module_search_space: - cls = getattr(m, class_name, None) - if cls: - return cls - return None - - class DependsOnMixin: def get_depends_on(self): """ @@ -139,12 +108,6 @@ class AuthenticationBackendsField(fields.StringListField): # authentication backend. REQUIRED_BACKEND_SETTINGS = collections.OrderedDict( [ - ('awx.sso.backends.LDAPBackend', ['AUTH_LDAP_SERVER_URI']), - ('awx.sso.backends.LDAPBackend1', ['AUTH_LDAP_1_SERVER_URI']), - ('awx.sso.backends.LDAPBackend2', ['AUTH_LDAP_2_SERVER_URI']), - ('awx.sso.backends.LDAPBackend3', ['AUTH_LDAP_3_SERVER_URI']), - ('awx.sso.backends.LDAPBackend4', ['AUTH_LDAP_4_SERVER_URI']), - ('awx.sso.backends.LDAPBackend5', ['AUTH_LDAP_5_SERVER_URI']), ('awx.sso.backends.RADIUSBackend', ['RADIUS_SERVER']), ('social_core.backends.google.GoogleOAuth2', ['SOCIAL_AUTH_GOOGLE_OAUTH2_KEY', 'SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET']), ('social_core.backends.github.GithubOAuth2', ['SOCIAL_AUTH_GITHUB_KEY', 'SOCIAL_AUTH_GITHUB_SECRET']), @@ -230,310 +193,6 @@ def _default_from_required_settings(self): return backends -class LDAPServerURIField(fields.URLField): - def __init__(self, **kwargs): - kwargs.setdefault('schemes', ('ldap', 'ldaps')) - kwargs.setdefault('allow_plain_hostname', True) - super(LDAPServerURIField, self).__init__(**kwargs) - - def run_validators(self, value): - for url in filter(None, re.split(r'[, ]', (value or ''))): - super(LDAPServerURIField, self).run_validators(url) - return value - - -class LDAPConnectionOptionsField(fields.DictField): - default_error_messages = {'invalid_options': _('Invalid connection option(s): {invalid_options}.')} - - def to_representation(self, value): - value = value or {} - opt_names = ldap.OPT_NAMES_DICT - # Convert integer options to their named constants. - repr_value = {} - for opt, opt_value in value.items(): - if opt in opt_names: - repr_value[opt_names[opt]] = opt_value - return repr_value - - def to_internal_value(self, data): - data = super(LDAPConnectionOptionsField, self).to_internal_value(data) - valid_options = dict([(v, k) for k, v in ldap.OPT_NAMES_DICT.items()]) - invalid_options = set(data.keys()) - set(valid_options.keys()) - if invalid_options: - invalid_options = sorted(list(invalid_options)) - options_display = json.dumps(invalid_options).lstrip('[').rstrip(']') - self.fail('invalid_options', invalid_options=options_display) - # Convert named options to their integer constants. - internal_data = {} - for opt_name, opt_value in data.items(): - internal_data[valid_options[opt_name]] = opt_value - return internal_data - - -class LDAPDNField(fields.CharField): - def __init__(self, **kwargs): - super(LDAPDNField, self).__init__(**kwargs) - self.validators.append(validate_ldap_dn) - - def run_validation(self, data=empty): - value = super(LDAPDNField, self).run_validation(data) - # django-auth-ldap expects DN fields (like AUTH_LDAP_REQUIRE_GROUP) - # to be either a valid string or ``None`` (not an empty string) - return None if value == '' else value - - -class LDAPDNListField(fields.StringListField): - def __init__(self, **kwargs): - super(LDAPDNListField, self).__init__(**kwargs) - self.validators.append(lambda dn: list(map(validate_ldap_dn, dn))) - - def run_validation(self, data=empty): - if not isinstance(data, (list, tuple)): - data = [data] - return super(LDAPDNListField, self).run_validation(data) - - -class LDAPDNWithUserField(fields.CharField): - def __init__(self, **kwargs): - super(LDAPDNWithUserField, self).__init__(**kwargs) - self.validators.append(validate_ldap_dn_with_user) - - def run_validation(self, data=empty): - value = super(LDAPDNWithUserField, self).run_validation(data) - # django-auth-ldap expects DN fields (like AUTH_LDAP_USER_DN_TEMPLATE) - # to be either a valid string or ``None`` (not an empty string) - return None if value == '' else value - - -class LDAPFilterField(fields.CharField): - def __init__(self, **kwargs): - super(LDAPFilterField, self).__init__(**kwargs) - self.validators.append(validate_ldap_filter) - - -class LDAPFilterWithUserField(fields.CharField): - def __init__(self, **kwargs): - super(LDAPFilterWithUserField, self).__init__(**kwargs) - self.validators.append(validate_ldap_filter_with_user) - - -class LDAPScopeField(fields.ChoiceField): - def __init__(self, choices=None, **kwargs): - choices = choices or [('SCOPE_BASE', _('Base')), ('SCOPE_ONELEVEL', _('One Level')), ('SCOPE_SUBTREE', _('Subtree'))] - super(LDAPScopeField, self).__init__(choices, **kwargs) - - def to_representation(self, value): - for choice in self.choices.keys(): - if value == getattr(ldap, choice): - return choice - return super(LDAPScopeField, self).to_representation(value) - - def to_internal_value(self, data): - value = super(LDAPScopeField, self).to_internal_value(data) - return getattr(ldap, value) - - -class LDAPSearchField(fields.ListField): - default_error_messages = { - 'invalid_length': _('Expected a list of three items but got {length} instead.'), - 'type_error': _('Expected an instance of LDAPSearch but got {input_type} instead.'), - } - ldap_filter_field_class = LDAPFilterField - - def to_representation(self, value): - if not value: - return [] - if not isinstance(value, LDAPSearch): - self.fail('type_error', input_type=type(value)) - return [ - LDAPDNField().to_representation(value.base_dn), - LDAPScopeField().to_representation(value.scope), - self.ldap_filter_field_class().to_representation(value.filterstr), - ] - - def to_internal_value(self, data): - data = super(LDAPSearchField, self).to_internal_value(data) - if len(data) == 0: - return None - if len(data) != 3: - self.fail('invalid_length', length=len(data)) - return LDAPSearch( - LDAPDNField().run_validation(data[0]), LDAPScopeField().run_validation(data[1]), self.ldap_filter_field_class().run_validation(data[2]) - ) - - -class LDAPSearchWithUserField(LDAPSearchField): - ldap_filter_field_class = LDAPFilterWithUserField - - -class LDAPSearchUnionField(fields.ListField): - default_error_messages = {'type_error': _('Expected an instance of LDAPSearch or LDAPSearchUnion but got {input_type} instead.')} - ldap_search_field_class = LDAPSearchWithUserField - - def to_representation(self, value): - if not value: - return [] - elif isinstance(value, LDAPSearchUnion): - return [self.ldap_search_field_class().to_representation(s) for s in value.searches] - elif isinstance(value, LDAPSearch): - return self.ldap_search_field_class().to_representation(value) - else: - self.fail('type_error', input_type=type(value)) - - def to_internal_value(self, data): - data = super(LDAPSearchUnionField, self).to_internal_value(data) - if len(data) == 0: - return None - if len(data) == 3 and isinstance(data[0], str): - return self.ldap_search_field_class().run_validation(data) - else: - search_args = [] - for i in range(len(data)): - if not isinstance(data[i], list): - raise ValidationError('In order to ultilize LDAP Union, input element No. %d should be a search query array.' % (i + 1)) - try: - search_args.append(self.ldap_search_field_class().run_validation(data[i])) - except Exception as e: - if hasattr(e, 'detail') and isinstance(e.detail, list): - e.detail.insert(0, "Error parsing LDAP Union element No. %d:" % (i + 1)) - raise e - return LDAPSearchUnion(*search_args) - - -class LDAPUserAttrMapField(fields.DictField): - default_error_messages = {'invalid_attrs': _('Invalid user attribute(s): {invalid_attrs}.')} - valid_user_attrs = {'first_name', 'last_name', 'email'} - child = fields.CharField() - - def to_internal_value(self, data): - data = super(LDAPUserAttrMapField, self).to_internal_value(data) - invalid_attrs = set(data.keys()) - self.valid_user_attrs - if invalid_attrs: - invalid_attrs = sorted(list(invalid_attrs)) - attrs_display = json.dumps(invalid_attrs).lstrip('[').rstrip(']') - self.fail('invalid_attrs', invalid_attrs=attrs_display) - return data - - -class LDAPGroupTypeField(fields.ChoiceField, DependsOnMixin): - default_error_messages = { - 'type_error': _('Expected an instance of LDAPGroupType but got {input_type} instead.'), - 'missing_parameters': _('Missing required parameters in {dependency}.'), - 'invalid_parameters': _('Invalid group_type parameters. Expected instance of dict but got {parameters_type} instead.'), - } - - def __init__(self, choices=None, **kwargs): - group_types = get_subclasses(django_auth_ldap.config.LDAPGroupType) - choices = choices or [(x.__name__, x.__name__) for x in group_types] - super(LDAPGroupTypeField, self).__init__(choices, **kwargs) - - def to_representation(self, value): - if not value: - return 'MemberDNGroupType' - if not isinstance(value, django_auth_ldap.config.LDAPGroupType): - self.fail('type_error', input_type=type(value)) - return value.__class__.__name__ - - def to_internal_value(self, data): - data = super(LDAPGroupTypeField, self).to_internal_value(data) - if not data: - return None - - cls = find_class_in_modules(data) - if not cls: - return None - - # Per-group type parameter validation and handling here - - # Backwords compatability. Before AUTH_LDAP_GROUP_TYPE_PARAMS existed - # MemberDNGroupType was the only group type, of the underlying lib, that - # took a parameter. - params = self.get_depends_on() or {} - params_sanitized = dict() - - cls_args = inspect.getfullargspec(cls.__init__).args[1:] - - if cls_args: - if not isinstance(params, dict): - self.fail('invalid_parameters', parameters_type=type(params)) - - for attr in cls_args: - if attr in params: - params_sanitized[attr] = params[attr] - - try: - return cls(**params_sanitized) - except TypeError: - self.fail('missing_parameters', dependency=list(self.depends_on)[0]) - - -class LDAPGroupTypeParamsField(fields.DictField, DependsOnMixin): - default_error_messages = {'invalid_keys': _('Invalid key(s): {invalid_keys}.')} - - def to_internal_value(self, value): - value = super(LDAPGroupTypeParamsField, self).to_internal_value(value) - if not value: - return value - group_type_str = self.get_depends_on() - group_type_str = group_type_str or '' - - group_type_cls = find_class_in_modules(group_type_str) - if not group_type_cls: - # Fail safe - return {} - - invalid_keys = set(value.keys()) - set(inspect.getfullargspec(group_type_cls.__init__).args[1:]) - if invalid_keys: - invalid_keys = sorted(list(invalid_keys)) - keys_display = json.dumps(invalid_keys).lstrip('[').rstrip(']') - self.fail('invalid_keys', invalid_keys=keys_display) - return value - - -class LDAPUserFlagsField(fields.DictField): - default_error_messages = {'invalid_flag': _('Invalid user flag: "{invalid_flag}".')} - valid_user_flags = {'is_superuser', 'is_system_auditor'} - child = LDAPDNListField() - - def to_internal_value(self, data): - data = super(LDAPUserFlagsField, self).to_internal_value(data) - invalid_flags = set(data.keys()) - self.valid_user_flags - if invalid_flags: - self.fail('invalid_flag', invalid_flag=list(invalid_flags)[0]) - return data - - -class LDAPDNMapField(fields.StringListBooleanField): - child = LDAPDNField() - - -class LDAPSingleOrganizationMapField(HybridDictField): - admins = LDAPDNMapField(allow_null=True, required=False) - users = LDAPDNMapField(allow_null=True, required=False) - auditors = LDAPDNMapField(allow_null=True, required=False) - remove_admins = fields.BooleanField(required=False) - remove_users = fields.BooleanField(required=False) - remove_auditors = fields.BooleanField(required=False) - - child = _Forbidden() - - -class LDAPOrganizationMapField(fields.DictField): - child = LDAPSingleOrganizationMapField() - - -class LDAPSingleTeamMapField(HybridDictField): - organization = fields.CharField() - users = LDAPDNMapField(allow_null=True, required=False) - remove = fields.BooleanField(required=False) - - child = _Forbidden() - - -class LDAPTeamMapField(fields.DictField): - child = LDAPSingleTeamMapField() - - class SocialMapStringRegexField(fields.CharField): def to_representation(self, value): if isinstance(value, type(re.compile(''))): diff --git a/awx/sso/ldap_group_types.py b/awx/sso/ldap_group_types.py deleted file mode 100644 index 2a5434c15440..000000000000 --- a/awx/sso/ldap_group_types.py +++ /dev/null @@ -1,73 +0,0 @@ -# Copyright (c) 2018 Ansible by Red Hat -# All Rights Reserved. - -# Python -import ldap - -# Django -from django.utils.encoding import force_str - -# 3rd party -from django_auth_ldap.config import LDAPGroupType - - -class PosixUIDGroupType(LDAPGroupType): - def __init__(self, name_attr='cn', ldap_group_user_attr='uid'): - self.ldap_group_user_attr = ldap_group_user_attr - super(PosixUIDGroupType, self).__init__(name_attr) - - """ - An LDAPGroupType subclass that handles non-standard DS. - """ - - def user_groups(self, ldap_user, group_search): - """ - Searches for any group that is either the user's primary or contains the - user as a member. - """ - groups = [] - - try: - user_uid = ldap_user.attrs[self.ldap_group_user_attr][0] - - if 'gidNumber' in ldap_user.attrs: - user_gid = ldap_user.attrs['gidNumber'][0] - filterstr = u'(|(gidNumber=%s)(memberUid=%s))' % ( - self.ldap.filter.escape_filter_chars(user_gid), - self.ldap.filter.escape_filter_chars(user_uid), - ) - else: - filterstr = u'(memberUid=%s)' % (self.ldap.filter.escape_filter_chars(user_uid),) - - search = group_search.search_with_additional_term_string(filterstr) - search.attrlist = [str(self.name_attr)] - groups = search.execute(ldap_user.connection) - except (KeyError, IndexError): - pass - - return groups - - def is_member(self, ldap_user, group_dn): - """ - Returns True if the group is the user's primary group or if the user is - listed in the group's memberUid attribute. - """ - is_member = False - try: - user_uid = ldap_user.attrs[self.ldap_group_user_attr][0] - - try: - is_member = ldap_user.connection.compare_s(force_str(group_dn), 'memberUid', force_str(user_uid)) - except (ldap.UNDEFINED_TYPE, ldap.NO_SUCH_ATTRIBUTE): - is_member = False - - if not is_member: - try: - user_gid = ldap_user.attrs['gidNumber'][0] - is_member = ldap_user.connection.compare_s(force_str(group_dn), 'gidNumber', force_str(user_gid)) - except (ldap.UNDEFINED_TYPE, ldap.NO_SUCH_ATTRIBUTE): - is_member = False - except (KeyError, IndexError): - is_member = False - - return is_member diff --git a/awx/sso/tests/functional/test_backends.py b/awx/sso/tests/functional/test_backends.py deleted file mode 100644 index a0d2c31da3e2..000000000000 --- a/awx/sso/tests/functional/test_backends.py +++ /dev/null @@ -1,115 +0,0 @@ -import pytest -from awx.sso.backends import _update_m2m_from_groups - - -class MockLDAPGroups(object): - def is_member_of(self, group_dn): - return bool(group_dn) - - -class MockLDAPUser(object): - def _get_groups(self): - return MockLDAPGroups() - - -@pytest.mark.parametrize( - "setting, expected_result", - [ - (True, True), - ('something', True), - (False, False), - ('', False), - ], -) -def test_mock_objects(setting, expected_result): - ldap_user = MockLDAPUser() - assert ldap_user._get_groups().is_member_of(setting) == expected_result - - -@pytest.mark.parametrize( - "opts, remove, expected_result", - [ - # In these case we will pass no opts so we should get None as a return in all cases - ( - None, - False, - None, - ), - ( - None, - True, - None, - ), - # Next lets test with empty opts ([]) This should return False if remove is True and None otherwise - ( - [], - True, - False, - ), - ( - [], - False, - None, - ), - # Next opts is True, this will always return True - ( - True, - True, - True, - ), - ( - True, - False, - True, - ), - # If we get only a non-string as an option we hit a continue and will either return None or False depending on the remove flag - ( - [32], - False, - None, - ), - ( - [32], - True, - False, - ), - # Finally we need to test whether or not a user should be allowed in or not. - # We use a mock class for ldap_user that simply returns true/false based on the otps - ( - ['true'], - False, - True, - ), - # In this test we are going to pass a string to test the part of the code that coverts strings into array, this should give us True - ( - 'something', - True, - True, - ), - ( - [''], - False, - None, - ), - ( - False, - True, - False, - ), - # Empty strings are considered opts == None and will result in None or False based on the remove flag - ( - '', - True, - False, - ), - ( - '', - False, - None, - ), - ], -) -@pytest.mark.django_db -def test__update_m2m_from_groups(opts, remove, expected_result): - ldap_user = MockLDAPUser() - assert expected_result == _update_m2m_from_groups(ldap_user, opts, remove) diff --git a/awx/sso/tests/functional/test_common.py b/awx/sso/tests/functional/test_common.py index f2b3e5781d90..18e485c6a10c 100644 --- a/awx/sso/tests/functional/test_common.py +++ b/awx/sso/tests/functional/test_common.py @@ -293,18 +293,17 @@ def test_get_or_create_org_with_default_galaxy_cred_no_galaxy_cred(self, galaxy_ assert o.galaxy_credentials.count() == 0 @pytest.mark.parametrize( - "enable_ldap, enable_social, enable_enterprise, expected_results", + "enable_social, enable_enterprise, expected_results", [ - (False, False, False, None), - (True, False, False, 'ldap'), - (True, True, False, 'social'), - (True, True, True, 'enterprise'), - (False, True, True, 'enterprise'), - (False, False, True, 'enterprise'), - (False, True, False, 'social'), + (False, False, None), + (True, False, 'social'), + (True, True, 'enterprise'), + (True, True, 'enterprise'), + (False, True, 'enterprise'), + (True, False, 'social'), ], ) - def test_get_external_account(self, enable_ldap, enable_social, enable_enterprise, expected_results): + def test_get_external_account(self, enable_social, enable_enterprise, expected_results): try: user = User.objects.get(username="external_tester") except User.DoesNotExist: @@ -312,8 +311,6 @@ def test_get_external_account(self, enable_ldap, enable_social, enable_enterpris user.set_unusable_password() user.save() - if enable_ldap: - user.profile.ldap_dn = 'test.dn' if enable_social: from social_django.models import UserSocialAuth @@ -337,8 +334,6 @@ def test_get_external_account(self, enable_ldap, enable_social, enable_enterpris [ # Set none of the social auth settings ('JUNK_SETTING', False), - # Set the hard coded settings - ('AUTH_LDAP_SERVER_URI', True), ('SOCIAL_AUTH_SAML_ENABLED_IDPS', True), ('RADIUS_SERVER', True), ('TACACSPLUS_HOST', True), @@ -366,9 +361,8 @@ def test_is_remote_auth_enabled(self, setting, expected): "key_one, key_one_value, key_two, key_two_value, expected", [ ('JUNK_SETTING', True, 'JUNK2_SETTING', True, False), - ('AUTH_LDAP_SERVER_URI', True, 'SOCIAL_AUTH_AZUREAD_OAUTH2_KEY', True, True), ('JUNK_SETTING', True, 'SOCIAL_AUTH_AZUREAD_OAUTH2_KEY', True, True), - ('AUTH_LDAP_SERVER_URI', False, 'SOCIAL_AUTH_AZUREAD_OAUTH2_KEY', False, False), + ('JUNK_SETTING', True, 'SOCIAL_AUTH_AZUREAD_OAUTH2_KEY', False, False), ], ) def test_is_remote_auth_enabled_multiple_keys(self, key_one, key_one_value, key_two, key_two_value, expected): diff --git a/awx/sso/tests/functional/test_ldap.py b/awx/sso/tests/functional/test_ldap.py deleted file mode 100644 index 881ab29e2b4f..000000000000 --- a/awx/sso/tests/functional/test_ldap.py +++ /dev/null @@ -1,19 +0,0 @@ -from django.test.utils import override_settings -import ldap -import pytest - -from awx.sso.backends import LDAPSettings - - -@override_settings(AUTH_LDAP_CONNECTION_OPTIONS={ldap.OPT_NETWORK_TIMEOUT: 60}) -@pytest.mark.django_db -def test_ldap_with_custom_timeout(): - settings = LDAPSettings() - assert settings.CONNECTION_OPTIONS == {ldap.OPT_NETWORK_TIMEOUT: 60} - - -@override_settings(AUTH_LDAP_CONNECTION_OPTIONS={ldap.OPT_REFERRALS: 0}) -@pytest.mark.django_db -def test_ldap_with_missing_timeout(): - settings = LDAPSettings() - assert settings.CONNECTION_OPTIONS == {ldap.OPT_REFERRALS: 0, ldap.OPT_NETWORK_TIMEOUT: 30} diff --git a/awx/sso/tests/unit/test_fields.py b/awx/sso/tests/unit/test_fields.py index 35ab58d07fab..14f91d2f4218 100644 --- a/awx/sso/tests/unit/test_fields.py +++ b/awx/sso/tests/unit/test_fields.py @@ -1,9 +1,8 @@ import pytest -from unittest import mock from rest_framework.exceptions import ValidationError -from awx.sso.fields import SAMLOrgAttrField, SAMLTeamAttrField, SAMLUserFlagsAttrField, LDAPGroupTypeParamsField, LDAPServerURIField +from awx.sso.fields import SAMLOrgAttrField, SAMLTeamAttrField, SAMLUserFlagsAttrField class TestSAMLOrgAttrField: @@ -192,44 +191,3 @@ def test_internal_value_invalid(self, data, expected): field.to_internal_value(data) print(e.value.detail) assert e.value.detail == expected - - -class TestLDAPGroupTypeParamsField: - @pytest.mark.parametrize( - "group_type, data, expected", - [ - ('LDAPGroupType', {'name_attr': 'user', 'bob': ['a', 'b'], 'scooter': 'hello'}, ['Invalid key(s): "bob", "scooter".']), - ('MemberDNGroupType', {'name_attr': 'user', 'member_attr': 'west', 'bob': ['a', 'b'], 'scooter': 'hello'}, ['Invalid key(s): "bob", "scooter".']), - ( - 'PosixUIDGroupType', - {'name_attr': 'user', 'member_attr': 'west', 'ldap_group_user_attr': 'legacyThing', 'bob': ['a', 'b'], 'scooter': 'hello'}, - ['Invalid key(s): "bob", "member_attr", "scooter".'], - ), - ], - ) - def test_internal_value_invalid(self, group_type, data, expected): - field = LDAPGroupTypeParamsField() - field.get_depends_on = mock.MagicMock(return_value=group_type) - - with pytest.raises(ValidationError) as e: - field.to_internal_value(data) - assert e.value.detail == expected - - -class TestLDAPServerURIField: - @pytest.mark.parametrize( - "ldap_uri, exception, expected", - [ - (r'ldap://servername.com:444', None, r'ldap://servername.com:444'), - (r'ldap://servername.so3:444', None, r'ldap://servername.so3:444'), - (r'ldaps://servername3.s300:344', None, r'ldaps://servername3.s300:344'), - (r'ldap://servername.-so3:444', ValidationError, None), - ], - ) - def test_run_validators_valid(self, ldap_uri, exception, expected): - field = LDAPServerURIField() - if exception is None: - assert field.run_validators(ldap_uri) == expected - else: - with pytest.raises(exception): - field.run_validators(ldap_uri) diff --git a/awx/sso/tests/unit/test_ldap.py b/awx/sso/tests/unit/test_ldap.py deleted file mode 100644 index aa54aaa49dbe..000000000000 --- a/awx/sso/tests/unit/test_ldap.py +++ /dev/null @@ -1,25 +0,0 @@ -import ldap - -from awx.sso.backends import LDAPSettings -from awx.sso.validators import validate_ldap_filter -from django.core.cache import cache - - -def test_ldap_default_settings(mocker): - from_db = mocker.Mock(**{'order_by.return_value': []}) - mocker.patch('awx.conf.models.Setting.objects.filter', return_value=from_db) - settings = LDAPSettings() - assert settings.ORGANIZATION_MAP == {} - assert settings.TEAM_MAP == {} - - -def test_ldap_default_network_timeout(mocker): - cache.clear() # clearing cache avoids picking up stray default for OPT_REFERRALS - from_db = mocker.Mock(**{'order_by.return_value': []}) - mocker.patch('awx.conf.models.Setting.objects.filter', return_value=from_db) - settings = LDAPSettings() - assert settings.CONNECTION_OPTIONS[ldap.OPT_NETWORK_TIMEOUT] == 30 - - -def test_ldap_filter_validator(): - validate_ldap_filter('(test-uid=%(user)s)', with_user=True) diff --git a/awx/sso/validators.py b/awx/sso/validators.py index 478b86b36fc9..a93f22efb8f3 100644 --- a/awx/sso/validators.py +++ b/awx/sso/validators.py @@ -1,72 +1,12 @@ -# Python -import re - -# Python-LDAP -import ldap - # Django from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ __all__ = [ - 'validate_ldap_dn', - 'validate_ldap_dn_with_user', - 'validate_ldap_bind_dn', - 'validate_ldap_filter', - 'validate_ldap_filter_with_user', 'validate_tacacsplus_disallow_nonascii', ] -def validate_ldap_dn(value, with_user=False): - if with_user: - if '%(user)s' not in value: - raise ValidationError(_('DN must include "%%(user)s" placeholder for username: %s') % value) - dn_value = value.replace('%(user)s', 'USER') - else: - dn_value = value - try: - ldap.dn.str2dn(dn_value.encode('utf-8')) - except ldap.DECODING_ERROR: - raise ValidationError(_('Invalid DN: %s') % value) - - -def validate_ldap_dn_with_user(value): - validate_ldap_dn(value, with_user=True) - - -def validate_ldap_bind_dn(value): - if not re.match(r'^[A-Za-z][A-Za-z0-9._-]*?\\[A-Za-z0-9 ._-]+?$', value.strip()) and not re.match( - r'^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$', value.strip() - ): - validate_ldap_dn(value) - - -def validate_ldap_filter(value, with_user=False): - value = value.strip() - if not value: - return - if with_user: - if '%(user)s' not in value: - raise ValidationError(_('DN must include "%%(user)s" placeholder for username: %s') % value) - dn_value = value.replace('%(user)s', 'USER') - else: - dn_value = value - if re.match(r'^\([A-Za-z0-9-]+?=[^()]+?\)$', dn_value): - return - elif re.match(r'^\([&|!]\(.*?\)\)$', dn_value): - try: - map(validate_ldap_filter, ['(%s)' % x for x in dn_value[3:-2].split(')(')]) - return - except ValidationError: - pass - raise ValidationError(_('Invalid filter: %s') % value) - - -def validate_ldap_filter_with_user(value): - validate_ldap_filter(value, with_user=True) - - def validate_tacacsplus_disallow_nonascii(value): try: value.encode('ascii') diff --git a/awx_collection/plugins/modules/settings.py b/awx_collection/plugins/modules/settings.py index c911f77fcc6b..7314257463b2 100644 --- a/awx_collection/plugins/modules/settings.py +++ b/awx_collection/plugins/modules/settings.py @@ -52,21 +52,6 @@ name: "AWX_ISOLATION_SHOW_PATHS" value: "'/var/lib/awx/projects/', '/tmp'" register: testing_settings - -- name: Set the LDAP Auth Bind Password - settings: - name: "AUTH_LDAP_BIND_PASSWORD" - value: "Password" - no_log: true - -- name: Set all the LDAP Auth Bind Params - settings: - settings: - AUTH_LDAP_BIND_PASSWORD: "password" - AUTH_LDAP_USER_ATTR_MAP: - email: "mail" - first_name: "givenName" - last_name: "surname" ''' from ..module_utils.controller_api import ControllerAPIModule diff --git a/awx_collection/test/awx/test_settings.py b/awx_collection/test/awx/test_settings.py index 69e823b3b9cf..2e0de9d2e04d 100644 --- a/awx_collection/test/awx/test_settings.py +++ b/awx_collection/test/awx/test_settings.py @@ -7,36 +7,6 @@ from awx.conf.models import Setting -@pytest.mark.django_db -def test_setting_flat_value(run_module, admin_user): - the_value = 'CN=service_account,OU=ServiceAccounts,DC=domain,DC=company,DC=org' - result = run_module('settings', dict(name='AUTH_LDAP_BIND_DN', value=the_value), admin_user) - assert not result.get('failed', False), result.get('msg', result) - assert result.get('changed'), result - - assert Setting.objects.get(key='AUTH_LDAP_BIND_DN').value == the_value - - -@pytest.mark.django_db -def test_setting_dict_value(run_module, admin_user): - the_value = {'email': 'mail', 'first_name': 'givenName', 'last_name': 'surname'} - result = run_module('settings', dict(name='AUTH_LDAP_USER_ATTR_MAP', value=the_value), admin_user) - assert not result.get('failed', False), result.get('msg', result) - assert result.get('changed'), result - - assert Setting.objects.get(key='AUTH_LDAP_USER_ATTR_MAP').value == the_value - - -@pytest.mark.django_db -def test_setting_nested_type(run_module, admin_user): - the_value = {'email': 'mail', 'first_name': 'givenName', 'last_name': 'surname'} - result = run_module('settings', dict(settings={'AUTH_LDAP_USER_ATTR_MAP': the_value}), admin_user) - assert not result.get('failed', False), result.get('msg', result) - assert result.get('changed'), result - - assert Setting.objects.get(key='AUTH_LDAP_USER_ATTR_MAP').value == the_value - - @pytest.mark.django_db def test_setting_bool_value(run_module, admin_user): for the_value in (True, False): diff --git a/awxkit/awxkit/api/pages/settings.py b/awxkit/awxkit/api/pages/settings.py index 12fa7e2910ca..bb29612ee81c 100644 --- a/awxkit/awxkit/api/pages/settings.py +++ b/awxkit/awxkit/api/pages/settings.py @@ -18,7 +18,6 @@ class Setting(base.Base): resources.settings_github_team, resources.settings_google_oauth2, resources.settings_jobs, - resources.settings_ldap, resources.settings_radius, resources.settings_saml, resources.settings_system, diff --git a/awxkit/awxkit/api/resources.py b/awxkit/awxkit/api/resources.py index 4ffb70a9b563..7b73734e2a97 100644 --- a/awxkit/awxkit/api/resources.py +++ b/awxkit/awxkit/api/resources.py @@ -215,7 +215,6 @@ class Resources(object): _settings_github_team = 'settings/github-team/' _settings_google_oauth2 = 'settings/google-oauth2/' _settings_jobs = 'settings/jobs/' - _settings_ldap = 'settings/ldap/' _settings_logging = 'settings/logging/' _settings_named_url = 'settings/named-url/' _settings_radius = 'settings/radius/' diff --git a/docs/auth/README.md b/docs/auth/README.md index eb23268747a3..62be30a69358 100644 --- a/docs/auth/README.md +++ b/docs/auth/README.md @@ -11,12 +11,11 @@ When a user wants to log into AWX, she can explicitly choose some of the support * Microsoft Azure Active Directory (AD) OAuth2 On the other hand, the other authentication methods use the same types of login info (username and password), but authenticate using external auth systems rather than AWX's own database. If some of these methods are enabled, AWX will try authenticating using the enabled methods *before AWX's own authentication method*. The order of precedence is: -* LDAP * RADIUS * TACACS+ * SAML -AWX will try authenticating against each enabled authentication method *in the specified order*, meaning if the same username and password is valid in multiple enabled auth methods (*e.g.*, both LDAP and TACACS+), AWX will only use the first positive match (in the above example, log a user in via LDAP and skip TACACS+). +AWX will try authenticating against each enabled authentication method *in the specified order*, meaning if the same username and password is valid in multiple enabled auth methods (*e.g.*, both RADIUS and TACACS+), AWX will only use the first positive match (in the above example, log a user in via RADIUS and skip TACACS+). ## Notes: SAML users, RADIUS users and TACACS+ users are categorized as 'Enterprise' users. The following rules apply to Enterprise users: diff --git a/docs/auth/ldap.md b/docs/auth/ldap.md deleted file mode 100644 index d212fa7ca853..000000000000 --- a/docs/auth/ldap.md +++ /dev/null @@ -1,68 +0,0 @@ -# LDAP -The Lightweight Directory Access Protocol (LDAP) is an open, vendor-neutral, industry-standard application protocol for accessing and maintaining distributed directory information services over an Internet Protocol (IP) network. Directory services play an important role in developing intranet and Internet applications by allowing the sharing of information about users, systems, networks, services, and applications throughout the network. - - -# Configure LDAP Authentication - -Please see the [AWX documentation](https://ansible.readthedocs.io/projects/awx/en/latest/administration/ldap_auth.html) for basic LDAP configuration. - -LDAP Authentication provides duplicate sets of configuration fields for authentication with up to six different LDAP servers. -The default set of configuration fields take the form `AUTH_LDAP_`. Configuration fields for additional LDAP servers are numbered `AUTH_LDAP__`. - - -## Test Environment Setup - -Please see `README.md` of this repository: https://github.com/ansible/deploy_ldap - - -# Basic Setup for FreeIPA - -LDAP Server URI (append if you have multiple LDAPs) -`ldaps://{{serverip1}}:636` - -LDAP BIND DN (How to create a bind account in [FreeIPA](https://www.freeipa.org/page/Creating_a_binddn_for_Foreman) -`uid=awx-bind,cn=sysaccounts,cn=etc,dc=example,dc=com` - -LDAP BIND PASSWORD -`{{yourbindaccountpassword}}` - -LDAP USER DN TEMPLATE -`uid=%(user)s,cn=users,cn=accounts,dc=example,dc=com` - -LDAP GROUP TYPE -`NestedMemberDNGroupType` - -LDAP GROUP SEARCH -``` -[ -"cn=groups,cn=accounts,dc=example,dc=com", -"SCOPE_SUBTREE", -"(objectClass=groupOfNames)" -] -``` - -LDAP USER ATTRIBUTE MAP -``` -{ -"first_name": "givenName", -"last_name": "sn", -"email": "mail" -} -``` - -LDAP USER FLAGS BY GROUP -``` -{ -"is_superuser": "cn={{superusergroupname}},cn=groups,cn=accounts,dc=example,dc=com" -} -``` - -LDAP ORGANIZATION MAP -``` -{ -"{{yourorganizationname}}": { -"admins": "cn={{admingroupname}},cn=groups,cn=accounts,dc=example,dc=com", -"remove_admins": false -} -} -``` diff --git a/docs/docsite/rst/administration/configure_awx_authentication.rst b/docs/docsite/rst/administration/configure_awx_authentication.rst index f90576f918d5..c56dfc5937f5 100644 --- a/docs/docsite/rst/administration/configure_awx_authentication.rst +++ b/docs/docsite/rst/administration/configure_awx_authentication.rst @@ -1,4 +1,4 @@ -Through the AWX user interface, you can set up a simplified login through various authentication types: GitHub, Google, LDAP, and RADIUS. After you create and register your developer application with the appropriate service, you can set up authorizations for them. +Through the AWX user interface, you can set up a simplified login through various authentication types: GitHub, Google, and RADIUS. After you create and register your developer application with the appropriate service, you can set up authorizations for them. 1. From the left navigation bar, click **Settings**. @@ -7,8 +7,7 @@ Through the AWX user interface, you can set up a simplified login through variou - :ref:`ag_auth_azure` - :ref:`ag_auth_github` - :ref:`ag_auth_google_oauth2` -- :ref:`LDAP settings ` -- :ref:`ag_auth_radius` +- :ref:`ag_auth_radius` - :ref:`ag_auth_tacacs` Different authentication types require you to enter different information. Be sure to include all the information as required. diff --git a/docs/docsite/rst/administration/ent_auth.rst b/docs/docsite/rst/administration/ent_auth.rst index 73039f46779c..238893ecee3e 100644 --- a/docs/docsite/rst/administration/ent_auth.rst +++ b/docs/docsite/rst/administration/ent_auth.rst @@ -13,10 +13,6 @@ This section describes setting up authentication for the following enterprise sy .. contents:: :local: -.. note:: - - For LDAP authentication, see :ref:`ag_auth_ldap`. - Azure, RADIUS, and TACACS+ users are categorized as 'Enterprise' users. The following rules apply to Enterprise users: - Enterprise users can only be created via the first successful login attempt from remote authentication backend. @@ -62,13 +58,6 @@ For application registering basics in Azure AD, refer to the `Azure AD Identity .. _`Azure AD Identity Platform (v2)`: https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-overview - -LDAP Authentication ---------------------- - -Refer to the :ref:`ag_auth_ldap` section. - - .. _ag_auth_radius: RADIUS settings diff --git a/docs/docsite/rst/administration/index.rst b/docs/docsite/rst/administration/index.rst index 5ba867d856f0..247bc6db4abb 100644 --- a/docs/docsite/rst/administration/index.rst +++ b/docs/docsite/rst/administration/index.rst @@ -41,7 +41,6 @@ Need help or want to discuss AWX including the documentation? See the :ref:`Comm oauth2_token_auth social_auth ent_auth - ldap_auth authentication_timeout kerberos_auth session_limits diff --git a/docs/docsite/rst/administration/ldap_auth.rst b/docs/docsite/rst/administration/ldap_auth.rst deleted file mode 100644 index 62372d949b4a..000000000000 --- a/docs/docsite/rst/administration/ldap_auth.rst +++ /dev/null @@ -1,361 +0,0 @@ -.. _ag_auth_ldap: - -Setting up LDAP Authentication -================================ - -.. index:: - single: LDAP - pair: authentication; LDAP - -This chapter describes how to integrate LDAP authentication with AWX. - -.. note:: - - If the LDAP server you want to connect to has a certificate that is self-signed or signed by a corporate internal certificate authority (CA), the CA certificate must be added to the system's trusted CAs. Otherwise, connection to the LDAP server will result in an error that the certificate issuer is not recognized. - -Administrators use LDAP as a source for account authentication information for AWX users. User authentication is provided, but not the synchronization of user permissions and credentials. Organization membership (as well as the organization admin) and team memberships can be synchronized. - -When so configured, a user who logs in with an LDAP username and password automatically gets an AWX account created for them and they can be automatically placed into organizations as either regular users or organization administrators. - -Users created locally in the user interface, take precedence over those logging into controller for their first time with an alternative authentication solution. You must delete the local user if you want to re-use it with another authentication method, such as LDAP. - -Users created through an LDAP login cannot change their username, given name, surname, or set a local password for themselves. You can also configure this to restrict editing of other field names. - -To configure LDAP integration for AWX: - -1. First, create a user in LDAP that has access to read the entire LDAP structure. - -2. Test if you can make successful queries to the LDAP server, use the ``ldapsearch`` command, which is a command line tool that can be installed on AWX command line as well as on other Linux and OSX systems. Use the following command to query the ldap server, where *josie* and *Josie4Cloud* are replaced by attributes that work for your setup: - -:: - - ldapsearch -x -H ldap://win -D "CN=josie,CN=Users,DC=website,DC=com" -b "dc=website,dc=com" -w Josie4Cloud - -Here ``CN=josie,CN=users,DC=website,DC=com`` is the Distinguished Name of the connecting user. - -.. note:: - - The ``ldapsearch`` utility is not automatically pre-installed with AWX, however, you can install it from the ``openldap-clients`` package. - -3. In the AWX User Interface, click **Settings** from the left navigation and click to select **LDAP settings** from the list of Authentication options. - - - Multiple LDAP configurations are not needed per LDAP server, but you can configure multiple LDAP servers from this page, otherwise, leave the server at **Default**: - - .. image:: ../common/images/configure-awx-auth-ldap-servers.png - - | - - The equivalent API endpoints will show ``AUTH_LDAP_*`` repeated: ``AUTH_LDAP_1_*``, ``AUTH_LDAP_2_*``, ..., ``AUTH_LDAP_5_*`` to denote server designations. - - -4. To enter or modify the LDAP server address to connect to, click **Edit** and enter in the **LDAP Server URI** field using the same format as the one prepopulated in the text field: - -.. image:: ../common/images/configure-awx-auth-ldap-server-uri.png - -.. note:: - - Multiple LDAP servers may be specified by separating each with spaces or commas. Click the |help| icon to comply with proper syntax and rules. - -.. |help| image:: ../common/images/tooltips-icon.png - -5. Enter the password to use for the Binding user in the **LDAP Bind Password** text field. In this example, the password is 'passme': - -.. image:: ../common/images/configure-awx-auth-ldap-bind-pwd.png - -6. Click to select a group type from the **LDAP Group Type** drop-down menu list. - - LDAP Group Types include: - - - ``PosixGroupType`` - - ``GroupOfNamesType`` - - ``GroupOfUniqueNamesType`` - - ``ActiveDirectoryGroupType`` - - ``OrganizationalRoleGroupType`` - - ``MemberDNGroupType`` - - ``NISGroupType`` - - ``NestedGroupOfNamesType`` - - ``NestedGroupOfUniqueNamesType`` - - ``NestedActiveDirectoryGroupType`` - - ``NestedOrganizationalRoleGroupType`` - - ``NestedMemberDNGroupType`` - - ``PosixUIDGroupType`` - - The LDAP Group Types that are supported by leveraging the underlying `django-auth-ldap library`_. To specify the parameters for the selected group type, see :ref:`Step 15 ` below. - - .. _`django-auth-ldap library`: https://django-auth-ldap.readthedocs.io/en/latest/groups.html#types-of-groups - - -7. The **LDAP Start TLS** is disabled by default. To enable TLS when the LDAP connection is not using SSL/TLS, click the toggle to **ON**. - -.. image:: ../common/images/configure-awx-auth-ldap-start-tls.png - -8. Enter the Distinguished Name in the **LDAP Bind DN** text field to specify the user that AWX uses to connect (Bind) to the LDAP server. Below uses the example, ``CN=josie,CN=users,DC=website,DC=com``: - -.. image:: ../common/images/configure-awx-auth-ldap-bind-dn.png - - -9. If that name is stored in key ``sAMAccountName``, the **LDAP User DN Template** populates with ``(sAMAccountName=%(user)s)``. Active Directory stores the username to ``sAMAccountName``. Similarly, for OpenLDAP, the key is ``uid``--hence the line becomes ``(uid=%(user)s)``. - -10. Enter the group distinguish name to allow users within that group to access AWX in the **LDAP Require Group** field, using the same format as the one shown in the text field, ``CN=awx Users,OU=Users,DC=website,DC=com``. - -.. image:: ../common/images/configure-awx-auth-ldap-req-group.png - -11. Enter the group distinguish name to prevent users within that group to access AWX in the **LDAP Deny Group** field, using the same format as the one shown in the text field. In this example, leave the field blank. - - -12. Enter where to search for users while authenticating in the **LDAP User Search** field using the same format as the one shown in the text field. In this example, use: - -:: - - [ - "OU=Users,DC=website,DC=com", - "SCOPE_SUBTREE", - "(cn=%(user)s)" - ] - -The first line specifies where to search for users in the LDAP tree. In the above example, the users are searched recursively starting from ``DC=website,DC=com``. - -The second line specifies the scope where the users should be searched: - - - SCOPE_BASE: This value is used to indicate searching only the entry at the base DN, resulting in only that entry being returned - - SCOPE_ONELEVEL: This value is used to indicate searching all entries one level under the base DN - but not including the base DN and not including any entries under that one level under the base DN. - - SCOPE_SUBTREE: This value is used to indicate searching of all entries at all levels under and including the specified base DN. - -The third line specifies the key name where the user name is stored. - -.. image:: ../common/images/configure-awx-authen-ldap-user-search.png - -.. note:: - - For multiple search queries, the proper syntax is: - :: - - [ - [ - "OU=Users,DC=northamerica,DC=acme,DC=com", - "SCOPE_SUBTREE", - "(sAMAccountName=%(user)s)" - ], - [ - "OU=Users,DC=apac,DC=corp,DC=com", - "SCOPE_SUBTREE", - "(sAMAccountName=%(user)s)" - ], - [ - "OU=Users,DC=emea,DC=corp,DC=com", - "SCOPE_SUBTREE", - "(sAMAccountName=%(user)s)" - ] - ] - - -13. In the **LDAP Group Search** text field, specify which groups should be searched and how to search them. In this example, use: - -:: - - [ - "dc=example,dc=com", - "SCOPE_SUBTREE", - "(objectClass=group)" - ] - -- The first line specifies the BASE DN where the groups should be searched. -- The second lines specifies the scope and is the same as that for the user directive. -- The third line specifies what the ``objectclass`` of a group object is in the LDAP you are using. - -.. image:: ../common/images/configure-awx-authen-ldap-group-search.png - -14. Enter the user attributes in the **LDAP User Attribute Map** the text field. In this example, use: - -:: - - { - "first_name": "givenName", - "last_name": "sn", - "email": "mail" - } - - -The above example retrieves users by last name from the key ``sn``. You can use the same LDAP query for the user to figure out what keys they are stored under. - -.. image:: ../common/images/configure-awx-auth-ldap-user-attrb-map.png - -.. _ldap_grp_params: - -15. Depending on the selected **LDAP Group Type**, different parameters are available in the **LDAP Group Type Parameters** field to account for this. ``LDAP_GROUP_TYPE_PARAMS`` is a dictionary, which will be converted by AWX to kwargs and passed to the LDAP Group Type class selected. There are two common parameters used by any of the LDAP Group Type; ``name_attr`` and ``member_attr``. Where ``name_attr`` defaults to ``cn`` and ``member_attr`` defaults to ``member``: - - :: - - {"name_attr": "cn", "member_attr": "member"} - - To determine what parameters a specific LDAP Group Type expects. refer to the `django_auth_ldap`_ documentation around the classes ``init`` parameters. - - .. _`django_auth_ldap`: https://django-auth-ldap.readthedocs.io/en/latest/reference.html#django_auth_ldap.config.LDAPGroupType - - -16. Enter the user profile flags in the **LDAP User Flags by Group** the text field. In this example, use the following syntax to set LDAP users as "Superusers" and "Auditors": - -:: - - { - "is_superuser": "cn=superusers,ou=groups,dc=website,dc=com", - "is_system_auditor": "cn=auditors,ou=groups,dc=website,dc=com" - } - -The above example retrieves users who are flagged as superusers or as auditor in their profile. - -.. image:: ../common/images/configure-awx-auth-ldap-user-flags.png - -17. For details on completing the mapping fields, see :ref:`ag_ldap_org_team_maps`. - -.. image:: ../common/images/configure-ldap-orgs-teams-mapping.png - -18. Click **Save** when done. - -With these values entered on this form, you can now make a successful authentication with LDAP. - -.. note:: - - AWX does not actively sync users, but they are created during their initial login. - To improve performance associated with LDAP authentication, see :ref:`ldap_auth_perf_tips` at the end of this chapter. - - -.. _ag_ldap_org_team_maps: - -LDAP Organization and Team Mapping -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. index:: - single: organization mapping - single: LDAP mapping - pair: authentication; LDAP mapping - pair: authentication; organization mapping - pair: authentication; LDAP team mapping - pair: authentication; team mapping - single: team mapping - -You can control which users are placed into which organizations based on LDAP attributes (mapping out between your organization admins/users and LDAP groups). - -Keys are organization names. Organizations will be created if not present. Values are dictionaries defining the options for each organization's membership. For each organization, it is possible to specify what groups are automatically users of the organization and also what groups can administer the organization. - -**admins**: None, True/False, string or list/tuple of strings. - - If **None**, organization admins will not be updated based on LDAP values. - - If **True**, all users in LDAP will automatically be added as admins of the organization. - - If **False**, no LDAP users will be automatically added as admins of the organization. - - If a string or list of strings, specifies the group DN(s) that will be added of the organization if they match any of the specified groups. - -**remove_admins**: True/False. Defaults to **False**. - - When **True**, a user who is not an member of the given groups will be removed from the organization's administrative list. - -**users**: None, True/False, string or list/tuple of strings. Same rules apply as for **admins**. - -**remove_users**: True/False. Defaults to **False**. Same rules apply as **remove_admins**. - -:: - - { - "LDAP Organization": { - "admins": "cn=engineering_admins,ou=groups,dc=example,dc=com", - "remove_admins": false, - "users": [ - "cn=engineering,ou=groups,dc=example,dc=com", - "cn=sales,ou=groups,dc=example,dc=com", - "cn=it,ou=groups,dc=example,dc=com" - ], - "remove_users": false - }, - "LDAP Organization 2": { - "admins": [ - "cn=Administrators,cn=Builtin,dc=example,dc=com" - ], - "remove_admins": false, - "users": true, - "remove_users": false - } - } - -Mapping between team members (users) and LDAP groups. Keys are team names (will be created if not present). Values are dictionaries of options for each team's membership, where each can contain the following parameters: - -**organization**: string. The name of the organization to which the team belongs. The team will be created if the combination of organization and team name does not exist. The organization will first be created if it does not exist. - -**users**: None, True/False, string or list/tuple of strings. - - - If **None**, team members will not be updated. - - If **True/False**, all LDAP users will be added/removed as team members. - - If a string or list of strings, specifies the group DN(s). User will be added as a team member if the user is a member of ANY of these groups. - -**remove**: True/False. Defaults to **False**. When **True**, a user who is not a member of the given groups will be removed from the team. - -:: - - { - "LDAP Engineering": { - "organization": "LDAP Organization", - "users": "cn=engineering,ou=groups,dc=example,dc=com", - "remove": true - }, - "LDAP IT": { - "organization": "LDAP Organization", - "users": "cn=it,ou=groups,dc=example,dc=com", - "remove": true - }, - "LDAP Sales": { - "organization": "LDAP Organization", - "users": "cn=sales,ou=groups,dc=example,dc=com", - "remove": true - } - } - - -.. _ldap_logging: - -Enabling Logging for LDAP -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. index:: - single: LDAP - pair: authentication; LDAP - -To enable logging for LDAP, you must set the level to ``DEBUG`` in the Settings configuration window: - -1. Click **Settings** from the left navigation pane and click to select **Logging settings** from the System list of options. -2. Click **Edit**. -3. Set the **Logging Aggregator Level Threshold** field to **Debug**. - -.. image:: ../common/images/settings-system-logging-debug.png - -4. Click **Save** to save your changes. - - -Referrals -~~~~~~~~~~~ - -.. index:: - pair: LDAP; referrals - pair: troubleshooting; LDAP referrals - -Active Directory uses "referrals" in case the queried object is not available in its database. It has been noted that this does not work properly with the django LDAP client and, most of the time, it helps to disable referrals. Disable LDAP referrals by adding the following lines to your ``/etc/awx/conf.d/custom.py`` file: - - .. code-block:: bash - - AUTH_LDAP_GLOBAL_OPTIONS = { - ldap.OPT_REFERRALS: False, - } - - -.. _ldap_auth_perf_tips: - -LDAP authentication performance tips -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. index:: - pair: best practices; ldap - -When an LDAP user authenticates, by default, all user-related attributes will be updated in the database on each log in. In some environments, this operation can be skipped due to performance issues. To avoid it, you can disable the option `AUTH_LDAP_ALWAYS_UPDATE_USER`. - -.. warning:: - - - With this option set to False, no changes to LDAP user's attributes will be updated. Attributes will only be updated the first time the user is created. - diff --git a/docs/docsite/rst/administration/logging.rst b/docs/docsite/rst/administration/logging.rst index ff5453839a54..bf484ab9cd41 100644 --- a/docs/docsite/rst/administration/logging.rst +++ b/docs/docsite/rst/administration/logging.rst @@ -363,11 +363,3 @@ Troubleshoot Logging API 4XX Errors ~~~~~~~~~~~~~~~~~~~~ You can include the API error message for 4XX errors by modifying the log format for those messages. Refer to the :ref:`logging-api-400-error-config` section for more detail. - -LDAP -~~~~~~ -You can enable logging messages for the LDAP adapter. Refer to the :ref:`ldap_logging` section for more detail. - -SAML -~~~~~~~ -You can enable logging messages for the SAML adapter the same way you can enable logging for LDAP. Refer to the :ref:`ldap_logging` section for more detail. diff --git a/docs/docsite/rst/administration/oauth2_token_auth.rst b/docs/docsite/rst/administration/oauth2_token_auth.rst index e6a3497f5ec0..7ab83a16e6df 100644 --- a/docs/docsite/rst/administration/oauth2_token_auth.rst +++ b/docs/docsite/rst/administration/oauth2_token_auth.rst @@ -451,7 +451,7 @@ Revoking an access token by this method is the same as deleting the token resour .. note:: - The **Allow External Users to Create Oauth2 Tokens** (``ALLOW_OAUTH2_FOR_EXTERNAL_USERS`` in the API) setting is disabled by default. External users refer to users authenticated externally with a service like LDAP, or any of the other SSO services. This setting ensures external users cannot *create* their own tokens. If you enable then disable it, any tokens created by external users in the meantime will still exist, and are not automatically revoked. + The **Allow External Users to Create Oauth2 Tokens** (``ALLOW_OAUTH2_FOR_EXTERNAL_USERS`` in the API) setting is disabled by default. External users refer to users authenticated externally with services like SSO services. This setting ensures external users cannot *create* their own tokens. If you enable then disable it, any tokens created by external users in the meantime will still exist, and are not automatically revoked. Alternatively, you can use the ``manage`` utility, :ref:`ag_manage_utility_revoke_tokens`, to revoke tokens as described in the :ref:`ag_token_utility` section. diff --git a/docs/docsite/rst/administration/performance.rst b/docs/docsite/rst/administration/performance.rst index 32d3c96cd0b8..05b1c0f62be5 100644 --- a/docs/docsite/rst/administration/performance.rst +++ b/docs/docsite/rst/administration/performance.rst @@ -80,15 +80,6 @@ Metrics added in this release to track: - **callback_receiver_event_processing_avg_seconds** - Proxy for “how far behind the callback receiver workers are in processing output". If this number stays large, consider horizontally scaling the control plane and reducing the ``capacity_adjustment`` value on the node. -LDAP login and basic authentication -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -.. index:: - pair: improvements; LDAP - pair: improvements; basic auth - -Enhancements were made to the authentication backend that syncs LDAP configuration with the organizations and teams in the AWX. Logging in with large mappings between LDAP groups and organizations and teams is now up to 10 times faster than in previous versions. - - Capacity Planning ------------------ .. index:: @@ -382,4 +373,4 @@ For workloads with high levels of API interaction, best practices include: - Use dynamic inventory sources instead of individually creating inventory hosts via the API - Use webhook notifications instead of polling for job status -Since the published blog, additional observations have been made in the field regarding authentication methods. For automation clients that will make many requests in rapid succession, using tokens is a best practice, because depending on the type of user, there may be additional overhead when using basic authentication. For example, LDAP users using basic authentication trigger a process to reconcile if the LDAP user is correctly mapped to particular organizations, teams and roles. Refer to :ref:`ag_oauth2_token_auth` for detail on how to generate and use tokens. +Since the published blog, additional observations have been made in the field regarding authentication methods. For automation clients that will make many requests in rapid succession, using tokens is a best practice, because depending on the type of user, there may be additional overhead when using basic authentication. Refer to :ref:`ag_oauth2_token_auth` for detail on how to generate and use tokens. diff --git a/docs/docsite/rst/administration/secret_handling.rst b/docs/docsite/rst/administration/secret_handling.rst index 41b35d731008..fb5eb86e8c13 100644 --- a/docs/docsite/rst/administration/secret_handling.rst +++ b/docs/docsite/rst/administration/secret_handling.rst @@ -24,7 +24,7 @@ User passwords for local users ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ AWX hashes local AWX user passwords with the PBKDF2 algorithm using a SHA256 hash. Users who authenticate via external -account mechanisms (LDAP, SAML, OAuth, and others) do not have any password or secret stored. +account mechanisms (SAML, OAuth, and others) do not have any password or secret stored. Secret handling for operational use ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/docsite/rst/administration/security_best_practices.rst b/docs/docsite/rst/administration/security_best_practices.rst index 8695082fbf86..e5d739559325 100644 --- a/docs/docsite/rst/administration/security_best_practices.rst +++ b/docs/docsite/rst/administration/security_best_practices.rst @@ -82,7 +82,7 @@ Do not disable SELinux, and do not disable AWX’s existing multi-tenant contain External account stores ^^^^^^^^^^^^^^^^^^^^^^^^^ -Maintaining a full set of users just in AWX can be a time-consuming task in a large organization, prone to error. AWX supports connecting to external account sources via :ref:`LDAP ` and certain :ref:`OAuth providers `. Using this eliminates a source of error when working with permissions. +Maintaining a full set of users just in AWX can be a time-consuming task in a large organization, prone to error. AWX supports connecting to external account sources via certain :ref:`OAuth providers `. Using this eliminates a source of error when working with permissions. .. _ag_security_django_password: diff --git a/docs/docsite/rst/administration/social_auth.rst b/docs/docsite/rst/administration/social_auth.rst index 9979fb0d3934..05a62f02019c 100644 --- a/docs/docsite/rst/administration/social_auth.rst +++ b/docs/docsite/rst/administration/social_auth.rst @@ -11,7 +11,7 @@ Authentication methods help simplify logins for end users--offering single sign- Account authentication can be configured in the AWX User Interface and saved to the PostgreSQL database. For instructions, refer to the :ref:`ag_configure_awx` section. -Account authentication in AWX can be configured to centrally use OAuth2, while enterprise-level account authentication can be configured for :ref:`Azure `, :ref:`RADIUS `, or even :ref:`LDAP ` as a source for authentication information. See :ref:`ag_ent_auth` for more detail. +Account authentication in AWX can be configured to centrally use OAuth2, while enterprise-level account authentication can be configured for :ref:`Azure `, :ref:`RADIUS ` as a source for authentication information. See :ref:`ag_ent_auth` for more detail. For websites, such as Microsoft Azure, Google or GitHub, that provide account information, account information is often implemented using the OAuth standard. OAuth is a secure authorization protocol which is commonly used in conjunction with account authentication to grant 3rd party applications a "session token" allowing them to make API calls to providers on the user’s behalf. diff --git a/docs/docsite/rst/administration/troubleshooting.rst b/docs/docsite/rst/administration/troubleshooting.rst index 43c9bfa8d909..f363695265b5 100644 --- a/docs/docsite/rst/administration/troubleshooting.rst +++ b/docs/docsite/rst/administration/troubleshooting.rst @@ -52,9 +52,6 @@ Example configuration of ``extra_settings`` parameter: - setting: MAX_PAGE_SIZE value: "500" - - setting: AUTH_LDAP_BIND_DN - value: "cn=admin,dc=example,dc=com" - - setting: LOG_AGGREGATOR_LEVEL value: "'DEBUG'" diff --git a/docs/docsite/rst/release_notes/known_issues.rst b/docs/docsite/rst/release_notes/known_issues.rst index ae685f152c2e..d92590a02ba2 100644 --- a/docs/docsite/rst/release_notes/known_issues.rst +++ b/docs/docsite/rst/release_notes/known_issues.rst @@ -14,7 +14,6 @@ Known Issues pair: known issues; live event statuses pair: live event statuses; green dot pair: live event statuses; red dot - pair: known issues; LDAP authentication pair: known issues; lost isolated jobs pair: known issues; sosreport pair: known issues; local management @@ -97,13 +96,6 @@ Misuse of job slicing can cause errors in job scheduling .. include:: ../common/job-slicing-rule.rst - -Default LDAP directory must be configured to use LDAP Authentication -====================================================================== - -The ability to configure up to six LDAP directories for authentication requires a value. On the settings page for LDAP, there is a "Default" LDAP configuration followed by five-numbered configuration slots. If the "Default" is not populated, AWX will not try to authenticate using the other directory configurations. - - Potential security issue using ``X_FORWARDED_FOR`` in ``REMOTE_HOST_HEADERS`` ================================================================================= diff --git a/docs/docsite/rst/userguide/overview.rst b/docs/docsite/rst/userguide/overview.rst index 59f44063f9a8..87390a3910d8 100644 --- a/docs/docsite/rst/userguide/overview.rst +++ b/docs/docsite/rst/userguide/overview.rst @@ -189,7 +189,7 @@ Authentication Enhancements pair: features; authentication pair: features; OAuth 2 token -AWX supports LDAP, SAML, token-based authentication. Enhanced LDAP and SAML support allows you to integrate your account information in a more flexible manner. Token-based Authentication allows for easily authentication of third-party tools and services with AWX via integrated OAuth 2 token support. +AWX supports SAML, token-based authentication. Enhanced SAML support allows you to integrate your account information in a more flexible manner. Token-based Authentication allows for easily authentication of third-party tools and services with AWX via integrated OAuth 2 token support. Cluster Management ~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/docsite/rst/userguide/rbac.rst b/docs/docsite/rst/userguide/rbac.rst index f94e95763721..1eb31e9f94ac 100644 --- a/docs/docsite/rst/userguide/rbac.rst +++ b/docs/docsite/rst/userguide/rbac.rst @@ -248,7 +248,7 @@ Often, you will have many Roles in a system and you will want some roles to incl .. |rbac-heirarchy-morecomplex| image:: ../common/images/rbac-heirarchy-morecomplex.png -RBAC controls also give you the capability to explicitly permit User and Teams of Users to run playbooks against certain sets of hosts. Users and teams are restricted to just the sets of playbooks and hosts to which they are granted capabilities. And, with AWX, you can create or import as many Users and Teams as you require--create users and teams manually or import them from LDAP or Active Directory. +RBAC controls also give you the capability to explicitly permit User and Teams of Users to run playbooks against certain sets of hosts. Users and teams are restricted to just the sets of playbooks and hosts to which they are granted capabilities. And, with AWX, you can create or import as many Users and Teams as you require--create users and teams manually or import them from Active Directory. RBACs are easiest to think of in terms of who or what can see, change, or delete an "object" for which a specific capability is being determined. diff --git a/licenses/django-auth-ldap.txt b/licenses/django-auth-ldap.txt deleted file mode 100644 index b16b2c01ff6a..000000000000 --- a/licenses/django-auth-ldap.txt +++ /dev/null @@ -1,23 +0,0 @@ -Copyright (c) 2009, Peter Sagerson -All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: - -- Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - -- Redistributions in binary form must reproduce the above copyright notice, this - list of conditions and the following disclaimer in the documentation and/or - other materials provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON -ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/licenses/python-ldap.txt b/licenses/python-ldap.txt deleted file mode 100644 index cece5f74cb92..000000000000 --- a/licenses/python-ldap.txt +++ /dev/null @@ -1,73 +0,0 @@ -The MIT License applies to contributions committed after July 1st, 2021, and -to all contributions by the following authors: - -* A. Karl Kornel -* Alex Willmer -* Aymeric Augustin -* Bernhard M. Wiedemann -* Bradley Baetz -* Christian Heimes -* Éloi Rivard -* Eyal Cherevatzki -* Florian Best -* Fred Thomsen -* Ivan A. Melnikov -* johnthagen -* Jonathon Reinhart -* Jon Dufresne -* Martin Basti -* Marti Raudsepp -* Miro Hrončok -* Paul Aurich -* Petr Viktorin -* Pieterjan De Potter -* Raphaël Barrois -* Robert Kuska -* Stanislav Láznička -* Tobias Bräutigam -* Tom van Dijk -* Wentao Han -* William Brown - - -------------------------------------------------------------------------------- - -MIT License - -Copyright (c) 2021 python-ldap contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - - - -Previous license: - -The python-ldap package is distributed under Python-style license. - -Standard disclaimer: - This software is made available by the author(s) to the public for free - and "as is". All users of this free software are solely and entirely - responsible for their own choice and use of this software for their - own purposes. By using this software, each user agrees that the - author(s) shall not be liable for damages of any kind in relation to - its use or performance. The author(s) do not warrant that this software - is fit for any purpose. - -$Id: LICENCE,v 1.1 2002/09/18 18:51:22 stroeder Exp $ diff --git a/requirements/requirements.in b/requirements/requirements.in index 8a2bd21ae664..a162b671e2d5 100644 --- a/requirements/requirements.in +++ b/requirements/requirements.in @@ -13,7 +13,6 @@ Cython<3 # due to https://github.com/yaml/pyyaml/pull/702 daphne distro django==4.2.10 # CVE-2024-24680 -django-auth-ldap django-cors-headers django-crum django-extensions @@ -52,7 +51,6 @@ pyparsing==2.4.6 # Upgrading to v3 of pyparsing introduce errors on smart host python-daemon>3.0.0 python-dsv-sdk>=1.0.4 python-tss-sdk>=1.2.1 -python-ldap pyyaml>=6.0.1 pyzstd # otel collector log file compression library receptorctl diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 2838fb6e15f1..b23b18880480 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -129,7 +129,6 @@ django==4.2.10 # -r /awx_devel/requirements/requirements.in # channels # django-ansible-base - # django-auth-ldap # django-cors-headers # django-crum # django-extensions @@ -140,8 +139,6 @@ django==4.2.10 # djangorestframework # social-auth-app-django # via -r /awx_devel/requirements/requirements_git.txt -django-auth-ldap==4.6.0 - # via -r /awx_devel/requirements/requirements.in django-cors-headers==4.3.1 # via -r /awx_devel/requirements/requirements.in django-crum==0.7.9 @@ -373,13 +370,11 @@ pyasn1==0.5.1 # via # pyasn1-modules # python-jose - # python-ldap # rsa # service-identity pyasn1-modules==0.3.0 # via # google-auth - # python-ldap # service-identity pycparser==2.21 # via cffi @@ -418,10 +413,6 @@ python-dsv-sdk==1.0.4 # via -r /awx_devel/requirements/requirements.in python-jose==3.3.0 # via social-auth-core -python-ldap==3.4.4 - # via - # -r /awx_devel/requirements/requirements.in - # django-auth-ldap python-string-utils==1.0.0 # via openshift python-tss-sdk==1.2.2 diff --git a/requirements/requirements_dev.txt b/requirements/requirements_dev.txt index 0d88a477fed5..7dfc0f67a942 100644 --- a/requirements/requirements_dev.txt +++ b/requirements/requirements_dev.txt @@ -19,7 +19,6 @@ logutils jupyter # matplotlib - Caused issues when bumping to setuptools 58 backports.tempfile # support in unit tests for py32+ tempfile.TemporaryDirectory -git+https://github.com/artefactual-labs/mockldap.git@master#egg=mockldap gprof2dot atomicwrites flake8 diff --git a/tools/ansible/roles/dockerfile/templates/Dockerfile.j2 b/tools/ansible/roles/dockerfile/templates/Dockerfile.j2 index dbfc821e89fe..0ab08ca6d108 100644 --- a/tools/ansible/roles/dockerfile/templates/Dockerfile.j2 +++ b/tools/ansible/roles/dockerfile/templates/Dockerfile.j2 @@ -44,7 +44,6 @@ RUN dnf -y update && dnf install -y 'dnf-command(config-manager)' && \ libtool-ltdl-devel \ make \ nss \ - openldap-devel \ patch \ postgresql \ postgresql-devel \ @@ -127,7 +126,6 @@ RUN dnf -y update && dnf install -y 'dnf-command(config-manager)' && \ glibc-langpack-en \ krb5-workstation \ nginx \ - "openldap >= 2.6.2-3" \ postgresql \ python3.11 \ "python3.11-devel" \ diff --git a/tools/docker-compose/README.md b/tools/docker-compose/README.md index 34db342021ff..77e10233bcdb 100644 --- a/tools/docker-compose/README.md +++ b/tools/docker-compose/README.md @@ -272,7 +272,6 @@ $ make docker-compose - [Start a Cluster](#start-a-cluster) - [Start with Minikube](#start-with-minikube) - [SAML and OIDC Integration](#saml-and-oidc-integration) -- [OpenLDAP Integration](#openldap-integration) - [Splunk Integration](#splunk-integration) - [tacacs+ Integration](#tacacs+-integration) @@ -436,41 +435,6 @@ Note: The OIDC adapter performs authentication only, not authorization. So any u If you Keycloak configuration is not working and you need to rerun the playbook to try a different `container_reference` or `oidc_reference` you can log into the Keycloak admin console on port 8443 and select the AWX realm in the upper left drop down. Then make sure you are on "Ream Settings" in the Configure menu option and click the trash can next to AWX in the main page window pane. This will completely remove the AWX ream (which has both SAML and OIDC settings) enabling you to re-run the plumb playbook. -### OpenLDAP Integration - -OpenLDAP is an LDAP provider that can be used to test AWX with LDAP integration. This section describes how to build a reference OpenLDAP instance and plumb it with your AWX for testing purposes. - -First, be sure that you have the awx.awx collection installed by running `make install_collection`. - -Anytime you want to run an OpenLDAP instance alongside AWX we can start docker-compose with the LDAP option to get an LDAP instance with the command: -```bash -LDAP=true make docker-compose -``` - -Once the containers come up two new ports (389, 636) should be exposed and the LDAP server should be running on those ports. The first port (389) is non-SSL and the second port (636) is SSL enabled. - -Now we are ready to configure and plumb OpenLDAP with AWX. To do this we have provided a playbook which will: -* Backup and configure the LDAP adapter in AWX. NOTE: this will back up your existing settings but the password fields can not be backed up through the API, you need a DB backup to recover this. - -Note: The default configuration will utilize the non-tls connection. If you want to use the tls configuration you will need to work through TLS negotiation issues because the LDAP server is using a self signed certificate. - -You can run the playbook like: -```bash -export CONTROLLER_USERNAME= -export CONTROLLER_PASSWORD= -ansible-playbook tools/docker-compose/ansible/plumb_ldap.yml -``` - - -Once the playbook is done running LDAP should now be setup in your development environment. This realm has four users with the following username/passwords: -1. awx_ldap_unpriv:unpriv123 -2. awx_ldap_admin:admin123 -3. awx_ldap_auditor:audit123 -4. awx_ldap_org_admin:orgadmin123 - -The first account is a normal user. The second account will be a super user in AWX. The third account will be a system auditor in AWX. The fourth account is an org admin. All users belong to an org called "LDAP Organization". To log in with one of these users go to the AWX login screen enter the username/password. - - ### Splunk Integration Splunk is a log aggregation tool that can be used to test AWX with external logging integration. This section describes how to build a reference Splunk instance and plumb it with your AWX for testing purposes. @@ -550,7 +514,7 @@ To create a secret connected to this vault in AWX you can run the following play ```bash export CONTROLLER_USERNAME= export CONTROLLER_PASSWORD= -ansible-playbook tools/docker-compose/ansible/plumb_vault.yml -e enable_ldap=false +ansible-playbook tools/docker-compose/ansible/plumb_vault.yml ``` This will create the following items in your AWX instance: @@ -575,53 +539,6 @@ If you have a playbook like: And run it through AWX with the credential `Credential From Vault via Token Auth` tied to it, the debug should result in `this_is_the_secret_value`. If you run it through AWX with the credential `Credential From Vault via Userpass Auth`, the debug should result in `this_is_the_userpass_secret_value`. -### HashiVault with LDAP - -If you wish to have your OpenLDAP container connected to the Vault container, you will first need to have the OpenLDAP container running alongside AWX and Vault. - - -```bash - -VAULT=true LDAP=true make docker-compose - -``` - -Similar to the above, you will need to unseal the vault before we can run the other needed playbooks. - -```bash - -ansible-playbook tools/docker-compose/ansible/unseal_vault.yml - -``` - -Now that the vault is unsealed, we can plumb the vault container now while passing true to enable_ldap extra var. - - -```bash - -export CONTROLLER_USERNAME= - -export CONTROLLER_PASSWORD= - -ansible-playbook tools/docker-compose/ansible/plumb_vault.yml -e enable_ldap=true - -``` - -This will populate your AWX instance with LDAP specific items. - -- A vault LDAP Lookup Cred tied to the LDAP `awx_ldap_vault` user called `Vault LDAP Lookup Cred` -- A credential called `Credential From HashiCorp Vault via LDAP Auth` which is of the created type using the `Vault LDAP Lookup Cred` to get the secret. - -And run it through AWX with the credential `Credential From HashiCorp Vault via LDAP Auth` tied to it, the debug should result in `this_is_the_ldap_secret_value`. - -The extremely non-obvious input is the fact that the fact prefixes "data/" unexpectedly. -This was discovered by inspecting the secret with the vault CLI, which may help with future troubleshooting. - -``` -docker exec -it -e VAULT_TOKEN= tools_vault_1 vault kv get --address=http://127.0.0.1:1234 my_engine/my_root/my_folder -``` - - ### Prometheus and Grafana integration See docs at https://github.com/ansible/awx/blob/devel/tools/grafana/README.md diff --git a/tools/docker-compose/ansible/plumb_ldap.yml b/tools/docker-compose/ansible/plumb_ldap.yml deleted file mode 100644 index 56b3dcdbabf2..000000000000 --- a/tools/docker-compose/ansible/plumb_ldap.yml +++ /dev/null @@ -1,32 +0,0 @@ ---- -- name: Plumb an ldap instance - hosts: localhost - connection: local - gather_facts: False - vars: - awx_host: "https://localhost:8043" - tasks: - - name: Load existing and new LDAP settings - ansible.builtin.set_fact: - existing_ldap: "{{ lookup('awx.awx.controller_api', 'settings/ldap', host=awx_host, verify_ssl=false) }}" - new_ldap: "{{ lookup('template', 'ldap_settings.json.j2') }}" - - - name: Display existing LDAP configuration - ansible.builtin.debug: - msg: - - "Here is your existing LDAP configuration for reference:" - - "{{ existing_ldap }}" - - - ansible.builtin.pause: - prompt: "Continuing to run this will replace your existing ldap settings (displayed above). They will all be captured. Be sure that is backed up before continuing" - - - name: Write out the existing content - ansible.builtin.copy: - dest: "../_sources/existing_ldap_adapter_settings.json" - content: "{{ existing_ldap }}" - - - name: Configure AWX LDAP adapter - awx.awx.settings: - settings: "{{ new_ldap }}" - controller_host: "{{ awx_host }}" - validate_certs: False diff --git a/tools/docker-compose/ansible/roles/sources/defaults/main.yml b/tools/docker-compose/ansible/roles/sources/defaults/main.yml index 669f2cfe2002..6bc110758056 100644 --- a/tools/docker-compose/ansible/roles/sources/defaults/main.yml +++ b/tools/docker-compose/ansible/roles/sources/defaults/main.yml @@ -23,15 +23,6 @@ work_sign_public_keyfile: "{{ work_sign_key_dir }}/work_public_key.pem" # SSO variables enable_keycloak: false -enable_ldap: false -ldap_public_key_file_name: 'ldap.cert' -ldap_private_key_file_name: 'ldap.key' -ldap_cert_dir: '{{ sources_dest }}/ldap_certs' -ldap_diff_dir: '{{ sources_dest }}/ldap_diffs' -ldap_public_key_file: '{{ ldap_cert_dir }}/{{ ldap_public_key_file_name }}' -ldap_private_key_file: '{{ ldap_cert_dir }}/{{ ldap_private_key_file_name }}' -ldap_cert_subject: "/C=US/ST=NC/L=Durham/O=awx/CN=" - # Hashicorp Vault enable_vault: false vault_tls: false diff --git a/tools/docker-compose/ansible/roles/sources/tasks/ldap.yml b/tools/docker-compose/ansible/roles/sources/tasks/ldap.yml deleted file mode 100644 index 1e0185a0885f..000000000000 --- a/tools/docker-compose/ansible/roles/sources/tasks/ldap.yml +++ /dev/null @@ -1,21 +0,0 @@ ---- -- name: Create LDAP cert directory - file: - path: "{{ item }}" - state: directory - loop: - - "{{ ldap_cert_dir }}" - - "{{ ldap_diff_dir }}" - -- name: include vault vars - include_vars: "{{ hashivault_vars_file }}" - -- name: General LDAP cert - command: 'openssl req -new -x509 -days 365 -nodes -out {{ ldap_public_key_file }} -keyout {{ ldap_private_key_file }} -subj "{{ ldap_cert_subject }}"' - args: - creates: "{{ ldap_public_key_file }}" - -- name: Copy ldap.diff - ansible.builtin.template: - src: "ldap.ldif.j2" - dest: "{{ ldap_diff_dir }}/ldap.ldif" diff --git a/tools/docker-compose/ansible/roles/sources/tasks/main.yml b/tools/docker-compose/ansible/roles/sources/tasks/main.yml index 0f1149053ebd..5637f6254601 100644 --- a/tools/docker-compose/ansible/roles/sources/tasks/main.yml +++ b/tools/docker-compose/ansible/roles/sources/tasks/main.yml @@ -97,10 +97,6 @@ creates: "{{ work_sign_public_keyfile }}" when: sign_work | bool -- name: Include LDAP tasks if enabled - include_tasks: ldap.yml - when: enable_ldap | bool - - name: Include vault TLS tasks if enabled include_tasks: vault_tls.yml when: enable_vault | bool diff --git a/tools/docker-compose/ansible/roles/sources/templates/docker-compose.yml.j2 b/tools/docker-compose/ansible/roles/sources/templates/docker-compose.yml.j2 index a56f861fda57..80f075ab4140 100644 --- a/tools/docker-compose/ansible/roles/sources/templates/docker-compose.yml.j2 +++ b/tools/docker-compose/ansible/roles/sources/templates/docker-compose.yml.j2 @@ -146,31 +146,6 @@ services: depends_on: - postgres {% endif %} -{% if enable_ldap|bool %} - ldap: - image: bitnami/openldap:2 - container_name: tools_ldap_1 - hostname: ldap - user: "{{ ansible_user_uid }}" - networks: - - awx - ports: - - "389:1389" - - "636:1636" - environment: - LDAP_ADMIN_USERNAME: admin - LDAP_ADMIN_PASSWORD: admin - LDAP_CUSTOM_LDIF_DIR: /opt/bitnami/openldap/ldiffs - LDAP_ENABLE_TLS: "yes" - LDAP_LDAPS_PORT_NUMBER: 1636 - LDAP_TLS_CERT_FILE: /opt/bitnami/openldap/certs/{{ ldap_public_key_file_name }} - LDAP_TLS_CA_FILE: /opt/bitnami/openldap/certs/{{ ldap_public_key_file_name }} - LDAP_TLS_KEY_FILE: /opt/bitnami/openldap/certs/{{ ldap_private_key_file_name }} - volumes: - - 'openldap_data:/bitnami/openldap' - - '../../docker-compose/_sources/ldap_certs:/opt/bitnami/openldap/certs' - - '../../docker-compose/_sources/ldap_diffs:/opt/bitnami/openldap/ldiffs' -{% endif %} {% if enable_splunk|bool %} splunk: image: splunk/splunk:latest @@ -376,11 +351,6 @@ volumes: redis_socket_{{ container_postfix }}: name: tools_redis_socket_{{ container_postfix }} {% endfor -%} -{% if enable_ldap|bool %} - openldap_data: - name: tools_ldap_1 - driver: local -{% endif %} {% if enable_vault|bool %} hashicorp_vault_data: name: tools_vault_1 diff --git a/tools/docker-compose/ansible/roles/sources/templates/ldap.ldif.j2 b/tools/docker-compose/ansible/roles/sources/templates/ldap.ldif.j2 deleted file mode 100644 index 9deaf836cd61..000000000000 --- a/tools/docker-compose/ansible/roles/sources/templates/ldap.ldif.j2 +++ /dev/null @@ -1,99 +0,0 @@ -dn: dc=example,dc=org -objectClass: dcObject -objectClass: organization -dc: example -o: example - -dn: ou=users,dc=example,dc=org -ou: users -objectClass: organizationalUnit - -dn: cn=awx_ldap_admin,ou=users,dc=example,dc=org -mail: admin@example.org -sn: LdapAdmin -cn: awx_ldap_admin -objectClass: top -objectClass: person -objectClass: organizationalPerson -objectClass: inetOrgPerson -userPassword: admin123 -givenName: awx - -dn: cn=awx_ldap_auditor,ou=users,dc=example,dc=org -mail: auditor@example.org -sn: LdapAuditor -cn: awx_ldap_auditor -objectClass: top -objectClass: person -objectClass: organizationalPerson -objectClass: inetOrgPerson -userPassword: audit123 -givenName: awx - -dn: cn=awx_ldap_unpriv,ou=users,dc=example,dc=org -mail: unpriv@example.org -sn: LdapUnpriv -cn: awx_ldap_unpriv -objectClass: top -objectClass: person -objectClass: organizationalPerson -objectClass: inetOrgPerson -givenName: awx -userPassword: unpriv123 - -dn: ou=groups,dc=example,dc=org -ou: groups -objectClass: top -objectClass: organizationalUnit - -dn: cn=awx_users,ou=groups,dc=example,dc=org -cn: awx_users -objectClass: top -objectClass: groupOfNames -member: cn=awx_ldap_admin,ou=users,dc=example,dc=org -member: cn=awx_ldap_auditor,ou=users,dc=example,dc=org -member: cn=awx_ldap_unpriv,ou=users,dc=example,dc=org -member: cn=awx_ldap_org_admin,ou=users,dc=example,dc=org - -dn: cn=awx_admins,ou=groups,dc=example,dc=org -cn: awx_admins -objectClass: top -objectClass: groupOfNames -member: cn=awx_ldap_admin,ou=users,dc=example,dc=org - -dn: cn=awx_auditors,ou=groups,dc=example,dc=org -cn: awx_auditors -objectClass: top -objectClass: groupOfNames -member: cn=awx_ldap_auditor,ou=users,dc=example,dc=org - -dn: cn=awx_ldap_org_admin,ou=users,dc=example,dc=org -mail: org.admin@example.org -sn: LdapOrgAdmin -cn: awx_ldap_org_admin -objectClass: top -objectClass: person -objectClass: organizationalPerson -objectClass: inetOrgPerson -givenName: awx -userPassword: orgadmin123 - -dn: cn=awx_org_admins,ou=groups,dc=example,dc=org -cn: awx_org_admins -objectClass: top -objectClass: groupOfNames -member: cn=awx_ldap_org_admin,ou=users,dc=example,dc=org - -{% if enable_ldap|bool and enable_vault|bool %} -dn: cn={{ vault_ldap_username }},ou=users,dc=example,dc=org -changetype: add -mail: vault@example.org -sn: LdapVaultAdmin -cn: {{ vault_ldap_username }} -objectClass: top -objectClass: person -objectClass: organizationalPerson -objectClass: inetOrgPerson -userPassword: {{ vault_ldap_password }} -givenName: awx -{% endif %} diff --git a/tools/docker-compose/ansible/roles/sources/templates/local_settings.py.j2 b/tools/docker-compose/ansible/roles/sources/templates/local_settings.py.j2 index 42a5d56366f4..1be38f43e28a 100644 --- a/tools/docker-compose/ansible/roles/sources/templates/local_settings.py.j2 +++ b/tools/docker-compose/ansible/roles/sources/templates/local_settings.py.j2 @@ -42,10 +42,6 @@ OPTIONAL_API_URLPATTERN_PREFIX = '{{ api_urlpattern_prefix }}' # Enable the following line to turn on database settings logging. # LOGGING['loggers']['awx.conf']['level'] = 'DEBUG' -# Enable the following lines to turn on LDAP auth logging. -# LOGGING['loggers']['django_auth_ldap']['handlers'] = ['console'] -# LOGGING['loggers']['django_auth_ldap']['level'] = 'DEBUG' - {% if enable_otel|bool %} LOGGING['handlers']['otel'] |= { 'class': 'awx.main.utils.handlers.OTLPHandler', diff --git a/tools/docker-compose/ansible/roles/vault/defaults/main.yml b/tools/docker-compose/ansible/roles/vault/defaults/main.yml index 58e0153b7f1e..36feeb28684a 100644 --- a/tools/docker-compose/ansible/roles/vault/defaults/main.yml +++ b/tools/docker-compose/ansible/roles/vault/defaults/main.yml @@ -5,8 +5,5 @@ vault_cert_dir: "{{ sources_dest }}/vault_certs" vault_server_cert: "{{ vault_cert_dir }}/server.crt" vault_client_cert: "{{ vault_cert_dir }}/client.crt" vault_client_key: "{{ vault_cert_dir }}/client.key" -ldap_ldif: "{{ sources_dest }}/ldap.ldifs/ldap.ldif" -vault_ldap_username: "awx_ldap_vault" -vault_ldap_password: "vault123" vault_userpass_username: "awx_userpass_admin" vault_userpass_password: "userpass123" diff --git a/tools/docker-compose/ansible/roles/vault/tasks/initialize.yml b/tools/docker-compose/ansible/roles/vault/tasks/initialize.yml index 8c7230c6d146..ac7d60b8ecfd 100644 --- a/tools/docker-compose/ansible/roles/vault/tasks/initialize.yml +++ b/tools/docker-compose/ansible/roles/vault/tasks/initialize.yml @@ -92,74 +92,6 @@ validate_certs: false token: "{{ Initial_Root_Token }}" - - name: Configure the vault ldap auth - block: - - name: Create ldap auth mount - flowerysong.hvault.write: - path: "sys/auth/ldap" - vault_addr: "{{ vault_addr_from_host }}" - validate_certs: false - token: "{{ Initial_Root_Token }}" - data: - type: "ldap" - register: vault_auth_ldap - changed_when: vault_auth_ldap.result.errors | default([]) | length == 0 - failed_when: - - vault_auth_ldap.result.errors | default([]) | length > 0 - - "'path is already in use at ldap/' not in vault_auth_ldap.result.errors | default([])" - - - name: Create ldap engine - flowerysong.hvault.engine: - path: "ldap_engine" - type: "kv" - vault_addr: "{{ vault_addr_from_host }}" - validate_certs: false - token: "{{ Initial_Root_Token }}" - - - name: Create a ldap secret - flowerysong.hvault.kv: - mount_point: "ldap_engine/ldaps_root" - key: "ldap_secret" - value: - my_key: "this_is_the_ldap_secret_value" - vault_addr: "{{ vault_addr_from_host }}" - validate_certs: false - token: "{{ Initial_Root_Token }}" - - - name: Configure ldap auth - flowerysong.hvault.ldap_config: - vault_addr: "{{ vault_addr_from_host }}" - validate_certs: false - token: "{{ Initial_Root_Token }}" - url: "ldap://ldap:1389" - binddn: "cn=awx_ldap_vault,ou=users,dc=example,dc=org" - bindpass: "vault123" - userdn: "ou=users,dc=example,dc=org" - deny_null_bind: "false" - discoverdn: "true" - - - name: Create ldap access policy - flowerysong.hvault.policy: - vault_addr: "{{ vault_addr_from_host }}" - validate_certs: false - token: "{{ Initial_Root_Token }}" - name: "ldap_engine" - policy: - ldap_engine/*: [create, read, update, delete, list] - sys/mounts:/*: [create, read, update, delete, list] - sys/mounts: [read] - - - name: Add awx_ldap_vault user to auth_method - flowerysong.hvault.ldap_user: - vault_addr: "{{ vault_addr_from_host }}" - validate_certs: false - token: "{{ Initial_Root_Token }}" - state: present - name: "{{ vault_ldap_username }}" - policies: - - "ldap_engine" - when: enable_ldap | bool - - name: Create userpass engine flowerysong.hvault.engine: path: "userpass_engine" diff --git a/tools/docker-compose/ansible/roles/vault/tasks/plumb.yml b/tools/docker-compose/ansible/roles/vault/tasks/plumb.yml index 0e87daef6fa5..f3fc709b84d5 100644 --- a/tools/docker-compose/ansible/roles/vault/tasks/plumb.yml +++ b/tools/docker-compose/ansible/roles/vault/tasks/plumb.yml @@ -78,56 +78,6 @@ secret_path: "/my_root/my_folder" secret_version: "" -- name: Create a HashiCorp Vault Credential for LDAP - awx.awx.credential: - credential_type: HashiCorp Vault Secret Lookup - name: Vault LDAP Lookup Cred - organization: Default - controller_host: "{{ awx_host }}" - controller_username: admin - controller_password: "{{ admin_password }}" - validate_certs: false - inputs: - api_version: "v1" - default_auth_path: "ldap" - kubernetes_role: "" - namespace: "" - url: "{{ vault_addr_from_container }}" - username: "{{ vault_ldap_username }}" - password: "{{ vault_ldap_password }}" - register: vault_ldap_cred - when: enable_ldap | bool - -- name: Create a credential from the Vault LDAP Custom Cred Type - awx.awx.credential: - credential_type: "{{ custom_vault_cred_type.id }}" - controller_host: "{{ awx_host }}" - controller_username: admin - controller_password: "{{ admin_password }}" - validate_certs: false - name: Credential From HashiCorp Vault via LDAP Auth - inputs: {} - organization: Default - register: custom_credential_via_ldap - when: enable_ldap | bool - -- name: Use the Vault LDAP Credential the new credential - awx.awx.credential_input_source: - input_field_name: password - target_credential: "{{ custom_credential_via_ldap.id }}" - source_credential: "{{ vault_ldap_cred.id }}" - controller_host: "{{ awx_host }}" - controller_username: admin - controller_password: "{{ admin_password }}" - validate_certs: false - metadata: - auth_path: "" - secret_backend: "ldap_engine" - secret_key: "my_key" - secret_path: "ldaps_root/ldap_secret" - secret_version: "" - when: enable_ldap | bool - - name: Create a HashiCorp Vault Credential for UserPass awx.awx.credential: credential_type: HashiCorp Vault Secret Lookup diff --git a/tools/docker-compose/ansible/templates/ldap_settings.json.j2 b/tools/docker-compose/ansible/templates/ldap_settings.json.j2 deleted file mode 100644 index 793270d7c93c..000000000000 --- a/tools/docker-compose/ansible/templates/ldap_settings.json.j2 +++ /dev/null @@ -1,52 +0,0 @@ -{ - "AUTH_LDAP_1_SERVER_URI": "ldap://ldap:1389", - "AUTH_LDAP_1_BIND_DN": "cn=admin,dc=example,dc=org", - "AUTH_LDAP_1_BIND_PASSWORD": "admin", - "AUTH_LDAP_1_START_TLS": false, - "AUTH_LDAP_1_CONNECTION_OPTIONS": { - "OPT_REFERRALS": 0, - "OPT_NETWORK_TIMEOUT": 30 - }, - "AUTH_LDAP_1_USER_SEARCH": [ - "ou=users,dc=example,dc=org", - "SCOPE_SUBTREE", - "(cn=%(user)s)" - ], - "AUTH_LDAP_1_USER_DN_TEMPLATE": "cn=%(user)s,ou=users,dc=example,dc=org", - "AUTH_LDAP_1_USER_ATTR_MAP": { - "first_name": "givenName", - "last_name": "sn", - "email": "mail" - }, - "AUTH_LDAP_1_GROUP_SEARCH": [ - "ou=groups,dc=example,dc=org", - "SCOPE_SUBTREE", - "(objectClass=groupOfNames)" - ], - "AUTH_LDAP_1_GROUP_TYPE": "MemberDNGroupType", - "AUTH_LDAP_1_GROUP_TYPE_PARAMS": { - "member_attr": "member", - "name_attr": "cn" - }, - "AUTH_LDAP_1_REQUIRE_GROUP": "cn=awx_users,ou=groups,dc=example,dc=org", - "AUTH_LDAP_1_DENY_GROUP": null, - "AUTH_LDAP_1_USER_FLAGS_BY_GROUP": { - "is_superuser": [ - "cn=awx_admins,ou=groups,dc=example,dc=org" - ], - "is_system_auditor": [ - "cn=awx_auditors,ou=groups,dc=example,dc=org" - ] - }, - "AUTH_LDAP_1_ORGANIZATION_MAP": { - "LDAP Organization": { - "users": true, - "remove_admins": false, - "remove_users": true, - "admins": [ - "cn=awx_org_admins,ou=groups,dc=example,dc=org" - ] - } - }, - "AUTH_LDAP_1_TEAM_MAP": {} -} From e664119c48dd12f8cd3180ea5ea6a881ad4809e9 Mon Sep 17 00:00:00 2001 From: Djebran Lezzoum Date: Wed, 2 Oct 2024 15:50:17 +0200 Subject: [PATCH 5/5] Remove TACACS+ authentication (#15547) Remove TACACS+ authentication from AWX. Co-authored-by: Hao Liu <44379968+TheRealHaoLiu@users.noreply.github.com> --- Makefile | 3 - .../0011_remove_tacacs_plus_auth_conf.py | 26 ++++ .../tests/functional/api/test_settings.py | 15 --- awx/settings/defaults.py | 10 -- awx/sso/backends.py | 51 -------- awx/sso/common.py | 2 - awx/sso/conf.py | 97 +-------------- awx/sso/fields.py | 1 - awx/sso/models.py | 1 + awx/sso/tests/conftest.py | 34 ----- awx/sso/tests/functional/test_common.py | 3 +- .../test_get_or_set_enterprise_user.py | 37 ------ awx/sso/tests/unit/test_tacacsplus.py | 116 ------------------ awx/sso/validators.py | 11 +- awxkit/awxkit/api/pages/settings.py | 1 - awxkit/awxkit/api/resources.py | 1 - docs/auth/README.md | 5 +- docs/auth/tacacsplus.md | 51 -------- .../configure_awx_authentication.rst | 2 - docs/docsite/rst/administration/ent_auth.rst | 36 ------ licenses/tacacs-plus.txt | 24 ---- requirements/requirements.in | 1 - requirements/requirements.txt | 2 - tools/docker-compose/README.md | 25 ---- tools/docker-compose/ansible/plumb_tacacs.yml | 32 ----- .../sources/templates/docker-compose.yml.j2 | 8 -- .../templates/tacacsplus_settings.json.j2 | 7 -- 27 files changed, 31 insertions(+), 571 deletions(-) create mode 100644 awx/conf/migrations/0011_remove_tacacs_plus_auth_conf.py delete mode 100644 awx/sso/tests/conftest.py delete mode 100644 awx/sso/tests/functional/test_get_or_set_enterprise_user.py delete mode 100644 awx/sso/tests/unit/test_tacacsplus.py delete mode 100644 docs/auth/tacacsplus.md delete mode 100644 licenses/tacacs-plus.txt delete mode 100644 tools/docker-compose/ansible/plumb_tacacs.yml delete mode 100644 tools/docker-compose/ansible/templates/tacacsplus_settings.json.j2 diff --git a/Makefile b/Makefile index 257590f091c8..686a9eb4ff2c 100644 --- a/Makefile +++ b/Makefile @@ -43,8 +43,6 @@ GRAFANA ?= false VAULT ?= false # If set to true docker-compose will also start a hashicorp vault instance with TLS enabled VAULT_TLS ?= false -# If set to true docker-compose will also start a tacacs+ instance -TACACS ?= false # If set to true docker-compose will also start an OpenTelemetry Collector instance OTEL ?= false # If set to true docker-compose will also start a Loki instance @@ -511,7 +509,6 @@ docker-compose-sources: .git/hooks/pre-commit -e enable_grafana=$(GRAFANA) \ -e enable_vault=$(VAULT) \ -e vault_tls=$(VAULT_TLS) \ - -e enable_tacacs=$(TACACS) \ -e enable_otel=$(OTEL) \ -e enable_loki=$(LOKI) \ -e install_editable_dependencies=$(EDITABLE_DEPENDENCIES) \ diff --git a/awx/conf/migrations/0011_remove_tacacs_plus_auth_conf.py b/awx/conf/migrations/0011_remove_tacacs_plus_auth_conf.py new file mode 100644 index 000000000000..229c40bcc138 --- /dev/null +++ b/awx/conf/migrations/0011_remove_tacacs_plus_auth_conf.py @@ -0,0 +1,26 @@ +from django.db import migrations + +TACACS_PLUS_AUTH_CONF_KEYS = [ + 'TACACSPLUS_HOST', + 'TACACSPLUS_PORT', + 'TACACSPLUS_SECRET', + 'TACACSPLUS_SESSION_TIMEOUT', + 'TACACSPLUS_AUTH_PROTOCOL', + 'TACACSPLUS_REM_ADDR', +] + + +def remove_tacacs_plus_auth_conf(apps, scheme_editor): + setting = apps.get_model('conf', 'Setting') + setting.objects.filter(key__in=TACACS_PLUS_AUTH_CONF_KEYS).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('conf', '0010_change_to_JSONField'), + ] + + operations = [ + migrations.RunPython(remove_tacacs_plus_auth_conf), + ] diff --git a/awx/main/tests/functional/api/test_settings.py b/awx/main/tests/functional/api/test_settings.py index 6f6c2a5e09af..67c92d8b9f0e 100644 --- a/awx/main/tests/functional/api/test_settings.py +++ b/awx/main/tests/functional/api/test_settings.py @@ -98,21 +98,6 @@ def test_radius_settings(get, put, patch, delete, admin, settings): assert settings.RADIUS_SECRET == '' -@pytest.mark.django_db -def test_tacacsplus_settings(get, put, patch, admin): - url = reverse('api:setting_singleton_detail', kwargs={'category_slug': 'tacacsplus'}) - response = get(url, user=admin, expect=200) - put(url, user=admin, data=response.data, expect=200) - patch(url, user=admin, data={'TACACSPLUS_SECRET': 'mysecret'}, expect=200) - patch(url, user=admin, data={'TACACSPLUS_SECRET': ''}, expect=200) - patch(url, user=admin, data={'TACACSPLUS_HOST': 'localhost'}, expect=400) - patch(url, user=admin, data={'TACACSPLUS_SECRET': 'mysecret'}, expect=200) - patch(url, user=admin, data={'TACACSPLUS_HOST': 'localhost'}, expect=200) - patch(url, user=admin, data={'TACACSPLUS_HOST': '', 'TACACSPLUS_SECRET': ''}, expect=200) - patch(url, user=admin, data={'TACACSPLUS_HOST': 'localhost', 'TACACSPLUS_SECRET': ''}, expect=400) - patch(url, user=admin, data={'TACACSPLUS_HOST': 'localhost', 'TACACSPLUS_SECRET': 'mysecret'}, expect=200) - - @pytest.mark.django_db def test_ui_settings(get, put, patch, delete, admin): url = reverse('api:setting_singleton_detail', kwargs={'category_slug': 'ui'}) diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index a1fa3ce479a7..05e8d2429bc8 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -388,7 +388,6 @@ AUTHENTICATION_BACKENDS = ( 'awx.sso.backends.RADIUSBackend', - 'awx.sso.backends.TACACSPlusBackend', 'social_core.backends.google.GoogleOAuth2', 'social_core.backends.github.GithubOAuth2', 'social_core.backends.github.GithubOrganizationOAuth2', @@ -419,15 +418,6 @@ RADIUS_PORT = 1812 RADIUS_SECRET = '' -# TACACS+ settings (default host to empty string to skip using TACACS+ auth). -# Note: These settings may be overridden by database settings. -TACACSPLUS_HOST = '' -TACACSPLUS_PORT = 49 -TACACSPLUS_SECRET = '' -TACACSPLUS_SESSION_TIMEOUT = 5 -TACACSPLUS_AUTH_PROTOCOL = 'ascii' -TACACSPLUS_REM_ADDR = False - # Enable / Disable HTTP Basic Authentication used in the API browser # Note: Session limits are not enforced when using HTTP Basic Authentication. # Note: This setting may be overridden by database settings. diff --git a/awx/sso/backends.py b/awx/sso/backends.py index 3b60cb223e67..7b29df23766d 100644 --- a/awx/sso/backends.py +++ b/awx/sso/backends.py @@ -13,9 +13,6 @@ # radiusauth from radiusauth.backends import RADIUSBackend as BaseRADIUSBackend -# tacacs+ auth -import tacacs_plus - # social from social_core.backends.saml import OID_USERID from social_core.backends.saml import SAMLAuth as BaseSAMLAuth @@ -69,54 +66,6 @@ def get_django_user(self, username, password=None, groups=[], is_staff=False, is return _get_or_set_enterprise_user(force_str(username), force_str(password), 'radius') -class TACACSPlusBackend(object): - """ - Custom TACACS+ auth backend for AWX - """ - - def authenticate(self, request, username, password): - if not django_settings.TACACSPLUS_HOST: - return None - try: - # Upstream TACACS+ client does not accept non-string, so convert if needed. - tacacs_client = tacacs_plus.TACACSClient( - django_settings.TACACSPLUS_HOST, - django_settings.TACACSPLUS_PORT, - django_settings.TACACSPLUS_SECRET, - timeout=django_settings.TACACSPLUS_SESSION_TIMEOUT, - ) - auth_kwargs = {'authen_type': tacacs_plus.TAC_PLUS_AUTHEN_TYPES[django_settings.TACACSPLUS_AUTH_PROTOCOL]} - if django_settings.TACACSPLUS_AUTH_PROTOCOL: - client_ip = self._get_client_ip(request) - if client_ip: - auth_kwargs['rem_addr'] = client_ip - auth = tacacs_client.authenticate(username, password, **auth_kwargs) - except Exception as e: - logger.exception("TACACS+ Authentication Error: %s" % str(e)) - return None - if auth.valid: - return _get_or_set_enterprise_user(username, password, 'tacacs+') - - def get_user(self, user_id): - if not django_settings.TACACSPLUS_HOST: - return None - try: - return User.objects.get(pk=user_id) - except User.DoesNotExist: - return None - - def _get_client_ip(self, request): - if not request or not hasattr(request, 'META'): - return None - - x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') - if x_forwarded_for: - ip = x_forwarded_for.split(',')[0] - else: - ip = request.META.get('REMOTE_ADDR') - return ip - - class TowerSAMLIdentityProvider(BaseSAMLIdentityProvider): """ Custom Identity Provider to make attributes to what we expect. diff --git a/awx/sso/common.py b/awx/sso/common.py index b57506f67dac..8f5b3a8b43b0 100644 --- a/awx/sso/common.py +++ b/awx/sso/common.py @@ -186,11 +186,9 @@ def get_external_account(user): def is_remote_auth_enabled(): from django.conf import settings - # Append Radius, TACACS+ and SAML options settings_that_turn_on_remote_auth = [ 'SOCIAL_AUTH_SAML_ENABLED_IDPS', 'RADIUS_SERVER', - 'TACACSPLUS_HOST', ] # Also include any SOCAIL_AUTH_*KEY (except SAML) for social_auth_key in dir(settings): diff --git a/awx/sso/conf.py b/awx/sso/conf.py index 332815deb6e7..44f7ec26b190 100644 --- a/awx/sso/conf.py +++ b/awx/sso/conf.py @@ -7,11 +7,8 @@ from django.urls import reverse from django.utils.translation import gettext_lazy as _ -# Django REST Framework -from rest_framework import serializers - # AWX -from awx.conf import register, register_validate, fields +from awx.conf import register, fields from awx.sso.fields import ( AuthenticationBackendsField, SAMLContactField, @@ -25,7 +22,6 @@ SocialTeamMapField, ) from awx.main.validators import validate_private_key, validate_certificate -from awx.sso.validators import validate_tacacsplus_disallow_nonascii # noqa class SocialAuthCallbackURL(object): @@ -187,79 +183,6 @@ def __call__(self): encrypted=True, ) - ############################################################################### - # TACACSPLUS AUTHENTICATION SETTINGS - ############################################################################### - - register( - 'TACACSPLUS_HOST', - field_class=fields.CharField, - allow_blank=True, - default='', - label=_('TACACS+ Server'), - help_text=_('Hostname of TACACS+ server.'), - category=_('TACACS+'), - category_slug='tacacsplus', - ) - - register( - 'TACACSPLUS_PORT', - field_class=fields.IntegerField, - min_value=1, - max_value=65535, - default=49, - label=_('TACACS+ Port'), - help_text=_('Port number of TACACS+ server.'), - category=_('TACACS+'), - category_slug='tacacsplus', - ) - - register( - 'TACACSPLUS_SECRET', - field_class=fields.CharField, - allow_blank=True, - default='', - validators=[validate_tacacsplus_disallow_nonascii], - label=_('TACACS+ Secret'), - help_text=_('Shared secret for authenticating to TACACS+ server.'), - category=_('TACACS+'), - category_slug='tacacsplus', - encrypted=True, - ) - - register( - 'TACACSPLUS_SESSION_TIMEOUT', - field_class=fields.IntegerField, - min_value=0, - default=5, - label=_('TACACS+ Auth Session Timeout'), - help_text=_('TACACS+ session timeout value in seconds, 0 disables timeout.'), - category=_('TACACS+'), - category_slug='tacacsplus', - unit=_('seconds'), - ) - - register( - 'TACACSPLUS_AUTH_PROTOCOL', - field_class=fields.ChoiceField, - choices=['ascii', 'pap'], - default='ascii', - label=_('TACACS+ Authentication Protocol'), - help_text=_('Choose the authentication protocol used by TACACS+ client.'), - category=_('TACACS+'), - category_slug='tacacsplus', - ) - - register( - 'TACACSPLUS_REM_ADDR', - field_class=fields.BooleanField, - default=True, - label=_('TACACS+ client address sending enabled'), - help_text=_('Enable the client address sending by TACACS+ client.'), - category=_('TACACS+'), - category_slug='tacacsplus', - ) - ############################################################################### # GOOGLE OAUTH2 AUTHENTICATION SETTINGS ############################################################################### @@ -1344,21 +1267,3 @@ def get_saml_entity_id(): category=_('Authentication'), category_slug='authentication', ) - - def tacacs_validate(serializer, attrs): - if not serializer.instance or not hasattr(serializer.instance, 'TACACSPLUS_HOST') or not hasattr(serializer.instance, 'TACACSPLUS_SECRET'): - return attrs - errors = [] - host = serializer.instance.TACACSPLUS_HOST - if 'TACACSPLUS_HOST' in attrs: - host = attrs['TACACSPLUS_HOST'] - secret = serializer.instance.TACACSPLUS_SECRET - if 'TACACSPLUS_SECRET' in attrs: - secret = attrs['TACACSPLUS_SECRET'] - if host and not secret: - errors.append('TACACSPLUS_SECRET is required when TACACSPLUS_HOST is provided.') - if errors: - raise serializers.ValidationError(_('\n'.join(errors))) - return attrs - - register_validate('tacacsplus', tacacs_validate) diff --git a/awx/sso/fields.py b/awx/sso/fields.py index d0ee30316992..872d18e69acf 100644 --- a/awx/sso/fields.py +++ b/awx/sso/fields.py @@ -14,7 +14,6 @@ # AWX from awx.conf import fields from awx.main.validators import validate_certificate -from awx.sso.validators import validate_tacacsplus_disallow_nonascii # noqa def get_subclasses(cls): diff --git a/awx/sso/models.py b/awx/sso/models.py index 28eb23857f4b..5973aa3c1090 100644 --- a/awx/sso/models.py +++ b/awx/sso/models.py @@ -7,6 +7,7 @@ from django.utils.translation import gettext_lazy as _ +# todo: this model to be removed as part of sso removal issue AAP-28380 class UserEnterpriseAuth(models.Model): """Enterprise Auth association model""" diff --git a/awx/sso/tests/conftest.py b/awx/sso/tests/conftest.py deleted file mode 100644 index f94b1c528f6d..000000000000 --- a/awx/sso/tests/conftest.py +++ /dev/null @@ -1,34 +0,0 @@ -import pytest - -from django.contrib.auth.models import User - -from awx.sso.backends import TACACSPlusBackend -from awx.sso.models import UserEnterpriseAuth - - -@pytest.fixture -def tacacsplus_backend(): - return TACACSPlusBackend() - - -@pytest.fixture -def existing_normal_user(): - try: - user = User.objects.get(username="alice") - except User.DoesNotExist: - user = User(username="alice", password="password") - user.save() - return user - - -@pytest.fixture -def existing_tacacsplus_user(): - try: - user = User.objects.get(username="foo") - except User.DoesNotExist: - user = User(username="foo") - user.set_unusable_password() - user.save() - enterprise_auth = UserEnterpriseAuth(user=user, provider='tacacs+') - enterprise_auth.save() - return user diff --git a/awx/sso/tests/functional/test_common.py b/awx/sso/tests/functional/test_common.py index 18e485c6a10c..430ea55a0426 100644 --- a/awx/sso/tests/functional/test_common.py +++ b/awx/sso/tests/functional/test_common.py @@ -324,7 +324,7 @@ def test_get_external_account(self, enable_social, enable_enterprise, expected_r if enable_enterprise: from awx.sso.models import UserEnterpriseAuth - enterprise_auth = UserEnterpriseAuth(user=user, provider='tacacs+') + enterprise_auth = UserEnterpriseAuth(user=user, provider='saml') enterprise_auth.save() assert get_external_account(user) == expected_results @@ -336,7 +336,6 @@ def test_get_external_account(self, enable_social, enable_enterprise, expected_r ('JUNK_SETTING', False), ('SOCIAL_AUTH_SAML_ENABLED_IDPS', True), ('RADIUS_SERVER', True), - ('TACACSPLUS_HOST', True), # Set some SOCIAL_SOCIAL_AUTH_OIDC_KEYAUTH_*_KEY settings ('SOCIAL_AUTH_AZUREAD_OAUTH2_KEY', True), ('SOCIAL_AUTH_GITHUB_ENTERPRISE_KEY', True), diff --git a/awx/sso/tests/functional/test_get_or_set_enterprise_user.py b/awx/sso/tests/functional/test_get_or_set_enterprise_user.py deleted file mode 100644 index 3f37b41df319..000000000000 --- a/awx/sso/tests/functional/test_get_or_set_enterprise_user.py +++ /dev/null @@ -1,37 +0,0 @@ -# Python -import pytest -from unittest import mock - -# AWX -from awx.sso.backends import _get_or_set_enterprise_user - - -@pytest.mark.django_db -def test_fetch_user_if_exist(existing_tacacsplus_user): - with mock.patch('awx.sso.backends.logger') as mocked_logger: - new_user = _get_or_set_enterprise_user("foo", "password", "tacacs+") - mocked_logger.debug.assert_not_called() - mocked_logger.warning.assert_not_called() - assert new_user == existing_tacacsplus_user - - -@pytest.mark.django_db -def test_create_user_if_not_exist(existing_tacacsplus_user): - with mock.patch('awx.sso.backends.logger') as mocked_logger: - new_user = _get_or_set_enterprise_user("bar", "password", "tacacs+") - mocked_logger.debug.assert_called_once_with(u'Created enterprise user bar via TACACS+ backend.') - assert new_user != existing_tacacsplus_user - - -@pytest.mark.django_db -def test_created_user_has_no_usable_password(): - new_user = _get_or_set_enterprise_user("bar", "password", "tacacs+") - assert not new_user.has_usable_password() - - -@pytest.mark.django_db -def test_non_enterprise_user_does_not_get_pass(existing_normal_user): - with mock.patch('awx.sso.backends.logger') as mocked_logger: - new_user = _get_or_set_enterprise_user("alice", "password", "tacacs+") - mocked_logger.warning.assert_called_once_with(u'Enterprise user alice already defined in Tower.') - assert new_user is None diff --git a/awx/sso/tests/unit/test_tacacsplus.py b/awx/sso/tests/unit/test_tacacsplus.py deleted file mode 100644 index 49315a96432c..000000000000 --- a/awx/sso/tests/unit/test_tacacsplus.py +++ /dev/null @@ -1,116 +0,0 @@ -from unittest import mock -import pytest - - -def test_empty_host_fails_auth(tacacsplus_backend): - with mock.patch('awx.sso.backends.django_settings') as settings: - settings.TACACSPLUS_HOST = '' - ret_user = tacacsplus_backend.authenticate(None, u"user", u"pass") - assert ret_user is None - - -def test_client_raises_exception(tacacsplus_backend): - client = mock.MagicMock() - client.authenticate.side_effect = Exception("foo") - with mock.patch('awx.sso.backends.django_settings') as settings, mock.patch('awx.sso.backends.logger') as logger, mock.patch( - 'tacacs_plus.TACACSClient', return_value=client - ): - settings.TACACSPLUS_HOST = 'localhost' - settings.TACACSPLUS_AUTH_PROTOCOL = 'ascii' - ret_user = tacacsplus_backend.authenticate(None, u"user", u"pass") - assert ret_user is None - logger.exception.assert_called_once_with("TACACS+ Authentication Error: foo") - - -def test_client_return_invalid_fails_auth(tacacsplus_backend): - auth = mock.MagicMock() - auth.valid = False - client = mock.MagicMock() - client.authenticate.return_value = auth - with mock.patch('awx.sso.backends.django_settings') as settings, mock.patch('tacacs_plus.TACACSClient', return_value=client): - settings.TACACSPLUS_HOST = 'localhost' - settings.TACACSPLUS_AUTH_PROTOCOL = 'ascii' - ret_user = tacacsplus_backend.authenticate(None, u"user", u"pass") - assert ret_user is None - - -def test_client_return_valid_passes_auth(tacacsplus_backend): - auth = mock.MagicMock() - auth.valid = True - client = mock.MagicMock() - client.authenticate.return_value = auth - user = mock.MagicMock() - user.has_usable_password = mock.MagicMock(return_value=False) - with mock.patch('awx.sso.backends.django_settings') as settings, mock.patch('tacacs_plus.TACACSClient', return_value=client), mock.patch( - 'awx.sso.backends._get_or_set_enterprise_user', return_value=user - ): - settings.TACACSPLUS_HOST = 'localhost' - settings.TACACSPLUS_AUTH_PROTOCOL = 'ascii' - ret_user = tacacsplus_backend.authenticate(None, u"user", u"pass") - assert ret_user == user - - -@pytest.mark.parametrize( - "client_ip_header,client_ip_header_value,expected_client_ip", - [('HTTP_X_FORWARDED_FOR', '12.34.56.78, 23.45.67.89', '12.34.56.78'), ('REMOTE_ADDR', '12.34.56.78', '12.34.56.78')], -) -def test_remote_addr_is_passed_to_client_if_available_and_setting_enabled(tacacsplus_backend, client_ip_header, client_ip_header_value, expected_client_ip): - auth = mock.MagicMock() - auth.valid = True - client = mock.MagicMock() - client.authenticate.return_value = auth - user = mock.MagicMock() - user.has_usable_password = mock.MagicMock(return_value=False) - request = mock.MagicMock() - request.META = { - client_ip_header: client_ip_header_value, - } - with mock.patch('awx.sso.backends.django_settings') as settings, mock.patch('tacacs_plus.TACACSClient', return_value=client), mock.patch( - 'awx.sso.backends._get_or_set_enterprise_user', return_value=user - ): - settings.TACACSPLUS_HOST = 'localhost' - settings.TACACSPLUS_AUTH_PROTOCOL = 'ascii' - settings.TACACSPLUS_REM_ADDR = True - tacacsplus_backend.authenticate(request, u"user", u"pass") - - client.authenticate.assert_called_once_with('user', 'pass', authen_type=1, rem_addr=expected_client_ip) - - -def test_remote_addr_is_completely_ignored_in_client_call_if_setting_is_disabled(tacacsplus_backend): - auth = mock.MagicMock() - auth.valid = True - client = mock.MagicMock() - client.authenticate.return_value = auth - user = mock.MagicMock() - user.has_usable_password = mock.MagicMock(return_value=False) - request = mock.MagicMock() - request.META = {} - with mock.patch('awx.sso.backends.django_settings') as settings, mock.patch('tacacs_plus.TACACSClient', return_value=client), mock.patch( - 'awx.sso.backends._get_or_set_enterprise_user', return_value=user - ): - settings.TACACSPLUS_HOST = 'localhost' - settings.TACACSPLUS_AUTH_PROTOCOL = 'ascii' - settings.TACACSPLUS_REM_ADDR = False - tacacsplus_backend.authenticate(request, u"user", u"pass") - - client.authenticate.assert_called_once_with('user', 'pass', authen_type=1) - - -def test_remote_addr_is_completely_ignored_in_client_call_if_unavailable_and_setting_enabled(tacacsplus_backend): - auth = mock.MagicMock() - auth.valid = True - client = mock.MagicMock() - client.authenticate.return_value = auth - user = mock.MagicMock() - user.has_usable_password = mock.MagicMock(return_value=False) - request = mock.MagicMock() - request.META = {} - with mock.patch('awx.sso.backends.django_settings') as settings, mock.patch('tacacs_plus.TACACSClient', return_value=client), mock.patch( - 'awx.sso.backends._get_or_set_enterprise_user', return_value=user - ): - settings.TACACSPLUS_HOST = 'localhost' - settings.TACACSPLUS_AUTH_PROTOCOL = 'ascii' - settings.TACACSPLUS_REM_ADDR = True - tacacsplus_backend.authenticate(request, u"user", u"pass") - - client.authenticate.assert_called_once_with('user', 'pass', authen_type=1) diff --git a/awx/sso/validators.py b/awx/sso/validators.py index a93f22efb8f3..07a582532a78 100644 --- a/awx/sso/validators.py +++ b/awx/sso/validators.py @@ -2,13 +2,4 @@ from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ -__all__ = [ - 'validate_tacacsplus_disallow_nonascii', -] - - -def validate_tacacsplus_disallow_nonascii(value): - try: - value.encode('ascii') - except (UnicodeEncodeError, UnicodeDecodeError): - raise ValidationError(_('TACACS+ secret does not allow non-ascii characters')) +__all__ = [] diff --git a/awxkit/awxkit/api/pages/settings.py b/awxkit/awxkit/api/pages/settings.py index bb29612ee81c..74807bcf5c4b 100644 --- a/awxkit/awxkit/api/pages/settings.py +++ b/awxkit/awxkit/api/pages/settings.py @@ -21,7 +21,6 @@ class Setting(base.Base): resources.settings_radius, resources.settings_saml, resources.settings_system, - resources.settings_tacacsplus, resources.settings_ui, resources.settings_user, resources.settings_user_defaults, diff --git a/awxkit/awxkit/api/resources.py b/awxkit/awxkit/api/resources.py index 7b73734e2a97..a14ec8730cd1 100644 --- a/awxkit/awxkit/api/resources.py +++ b/awxkit/awxkit/api/resources.py @@ -220,7 +220,6 @@ class Resources(object): _settings_radius = 'settings/radius/' _settings_saml = 'settings/saml/' _settings_system = 'settings/system/' - _settings_tacacsplus = 'settings/tacacsplus/' _settings_ui = 'settings/ui/' _settings_user = 'settings/user/' _settings_user_defaults = 'settings/user-defaults/' diff --git a/docs/auth/README.md b/docs/auth/README.md index 62be30a69358..92946746f06c 100644 --- a/docs/auth/README.md +++ b/docs/auth/README.md @@ -12,13 +12,10 @@ When a user wants to log into AWX, she can explicitly choose some of the support On the other hand, the other authentication methods use the same types of login info (username and password), but authenticate using external auth systems rather than AWX's own database. If some of these methods are enabled, AWX will try authenticating using the enabled methods *before AWX's own authentication method*. The order of precedence is: * RADIUS -* TACACS+ * SAML -AWX will try authenticating against each enabled authentication method *in the specified order*, meaning if the same username and password is valid in multiple enabled auth methods (*e.g.*, both RADIUS and TACACS+), AWX will only use the first positive match (in the above example, log a user in via RADIUS and skip TACACS+). - ## Notes: -SAML users, RADIUS users and TACACS+ users are categorized as 'Enterprise' users. The following rules apply to Enterprise users: +SAML users and RADIUS users are categorized as 'Enterprise' users. The following rules apply to Enterprise users: * Enterprise users can only be created via the first successful login attempt from remote authentication backend. * Enterprise users cannot be created/authenticated if non-enterprise users with the same name has already been created in AWX. diff --git a/docs/auth/tacacsplus.md b/docs/auth/tacacsplus.md deleted file mode 100644 index f895ed4aeb35..000000000000 --- a/docs/auth/tacacsplus.md +++ /dev/null @@ -1,51 +0,0 @@ -# TACACS+ -[Terminal Access Controller Access-Control System Plus (TACACS+)](https://en.wikipedia.org/wiki/TACACS) is a protocol developed by Cisco to handle remote authentication and related services for networked access control through a centralized server. In specific, TACACS+ provides authentication, authorization and accounting (AAA) services. AWX currently utilizes its authentication service. - -TACACS+ is configured by settings configuration and is available under `/api/v2/settings/tacacsplus/`. Here is a typical configuration with every configurable field included: -``` -{ - "TACACSPLUS_HOST": "127.0.0.1", - "TACACSPLUS_PORT": 49, - "TACACSPLUS_SECRET": "secret", - "TACACSPLUS_SESSION_TIMEOUT": 5, - "TACACSPLUS_AUTH_PROTOCOL": "ascii", - "TACACSPLUS_REM_ADDR": "false" -} -``` -Each field is explained below: - -| Field Name | Field Value Type | Field Value Default | Description | -|------------------------------|---------------------|---------------------|--------------------------------------------------------------------| -| `TACACSPLUS_HOST` | String | '' (empty string) | Hostname of TACACS+ server. Empty string disables TACACS+ service. | -| `TACACSPLUS_PORT` | Integer | 49 | Port number of TACACS+ server. | -| `TACACSPLUS_SECRET` | String | '' (empty string) | Shared secret for authenticating to TACACS+ server. | -| `TACACSPLUS_SESSION_TIMEOUT` | Integer | 5 | TACACS+ session timeout value in seconds. | -| `TACACSPLUS_AUTH_PROTOCOL` | String with choices | 'ascii' | The authentication protocol used by TACACS+ client (choices are `ascii` and `pap`). | -| `TACACSPLUS_REM_ADDR` | Boolean | false | Enable the client address sending by TACACS+ client. | - -Under the hood, AWX uses [open-source TACACS+ python client](https://github.com/ansible/tacacs_plus) to communicate with the remote TACACS+ server. During authentication, AWX passes username and password to TACACS+ client, which packs up auth information and sends it to the TACACS+ server. Based on what the server returns, AWX will invalidate login attempt if authentication fails. If authentication passes, AWX will create a user if she does not exist in database, and log the user in. - -## Test Environment Setup - -The suggested TACACS+ server for testing is [shrubbery TACACS+ daemon](http://www.shrubbery.net/tac_plus/). It is supposed to run on a CentOS machine. A verified candidate is CentOS 6.3 AMI in AWS EC2 Community AMIs (search for `CentOS 6.3 x86_64 HVM - Minimal with cloud-init aws-cfn-bootstrap and ec2-api-tools`). Note that it is required to keep TCP port 49 open, since it's the default port used by the TACACS+ daemon. - -We provide [a playbook](https://github.com/jangsutsr/ansible-role-tacacs) to install a working TACACS+ server. Here is a typical test setup using the provided playbook: - -1. In AWS EC2, spawn the CentOS 6 machine. -2. In Tower, create a test project using the stand-alone playbook inventory. -3. In Tower, create a test inventory with the only host to be the spawned CentOS machine. -4. In Tower, create and run a job template using the created project and inventory with parameters setup as below: - -![Example tacacs+ setup jt parameters](../img/auth_tacacsplus_1.png?raw=true) - -The playbook creates a user named 'tower' with ascii password default to 'login' and modifiable by `extra_var` `ascii_password` and pap password default to 'papme' and modifiable by `extra_var` `pap_password`. In order to configure TACACS+ server to meet custom test needs, we need to modify server-side file `/etc/tac_plus.conf` and `sudo service tac_plus restart` to restart the daemon. Details on how to modify config file can be found [here](http://manpages.ubuntu.com/manpages/xenial/man5/tac_plus.conf.5.html). - - -## Acceptance Criteria - -* All specified in configuration fields should be shown and configurable as documented. -* A user defined by the TACACS+ server should be able to log into AWX. -* User not defined by TACACS+ server should not be able to log into AWX via TACACS+. -* A user existing in TACACS+ server but not in AWX should be created after the first successful log in. -* TACACS+ backend should stop an authentication attempt after configured timeout and should not block the authentication pipeline in any case. -* If exceptions occur on TACACS+ server side, the exception details should be logged in AWX, and AWX should not authenticate that user via TACACS+. diff --git a/docs/docsite/rst/administration/configure_awx_authentication.rst b/docs/docsite/rst/administration/configure_awx_authentication.rst index c56dfc5937f5..01e610273fac 100644 --- a/docs/docsite/rst/administration/configure_awx_authentication.rst +++ b/docs/docsite/rst/administration/configure_awx_authentication.rst @@ -7,8 +7,6 @@ Through the AWX user interface, you can set up a simplified login through variou - :ref:`ag_auth_azure` - :ref:`ag_auth_github` - :ref:`ag_auth_google_oauth2` -- :ref:`ag_auth_radius` -- :ref:`ag_auth_tacacs` Different authentication types require you to enter different information. Be sure to include all the information as required. diff --git a/docs/docsite/rst/administration/ent_auth.rst b/docs/docsite/rst/administration/ent_auth.rst index 238893ecee3e..a31f4d1cadb0 100644 --- a/docs/docsite/rst/administration/ent_auth.rst +++ b/docs/docsite/rst/administration/ent_auth.rst @@ -13,8 +13,6 @@ This section describes setting up authentication for the following enterprise sy .. contents:: :local: -Azure, RADIUS, and TACACS+ users are categorized as 'Enterprise' users. The following rules apply to Enterprise users: - - Enterprise users can only be created via the first successful login attempt from remote authentication backend. - Enterprise users cannot be created/authenticated if non-enterprise users with the same name has already been created in AWX. - AWX passwords of enterprise users should always be empty and cannot be set by any user if there are enterprise backend-enabled. @@ -78,37 +76,3 @@ AWX can be configured to centrally use RADIUS as a source for authentication inf 4. Enter the port and secret information in the next two fields. 5. Click **Save** when done. - - -.. _ag_auth_tacacs: - -TACACS+ settings ------------------ - -.. index:: - pair: authentication; TACACS+ Authentication Settings - - -Terminal Access Controller Access-Control System Plus (TACACS+) is a protocol that handles remote authentication and related services for networked access control through a centralized server. In particular, TACACS+ provides authentication, authorization and accounting (AAA) services, in which you can configure AWX to use as a source for authentication. - -.. note:: - - This feature is deprecated and will be removed in a future release. - -1. Click **Settings** from the left navigation bar. - -2. On the left side of the Settings window, click **TACACs+ settings** from the list of Authentication options. - -3. Click **Edit** and enter information in the following fields: - -- **TACACS+ Server**: Provide the hostname or IP address of the TACACS+ server with which to authenticate. If this field is left blank, TACACS+ authentication is disabled. -- **TACACS+ Port**: TACACS+ uses port 49 by default, which is already pre-populated. -- **TACACS+ Secret**: Secret key for TACACS+ authentication server. -- **TACACS+ Auth Session Timeout**: Session timeout value in seconds. The default is 5 seconds. -- **TACACS+ Authentication Protocol**: The protocol used by TACACS+ client. Options are **ascii** or **pap**. - -.. image:: ../common/images/configure-awx-auth-tacacs.png - :alt: TACACS+ configuration details in AWX settings. - -4. Click **Save** when done. - diff --git a/licenses/tacacs-plus.txt b/licenses/tacacs-plus.txt deleted file mode 100644 index 56b2d91f18ad..000000000000 --- a/licenses/tacacs-plus.txt +++ /dev/null @@ -1,24 +0,0 @@ -# Copyright (c) 2017 Ansible by Red Hat -# All Rights Reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. - * Neither the name of the nor the - names of its contributors may be used to endorse or promote products - derived from this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY -DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/requirements/requirements.in b/requirements/requirements.in index a162b671e2d5..1814ef8df789 100644 --- a/requirements/requirements.in +++ b/requirements/requirements.in @@ -60,7 +60,6 @@ sqlparse>=0.4.4 # Required by django https://github.com/ansible/awx/security/d redis[hiredis] requests slack-sdk -tacacs_plus==1.0 # UPGRADE BLOCKER: auth does not work with later versions twilio twisted[tls]>=23.10.0 # CVE-2023-46137 uWSGI diff --git a/requirements/requirements.txt b/requirements/requirements.txt index b23b18880480..44e395fd75e3 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -495,7 +495,6 @@ six==1.16.0 # pygerduty # pyrad # python-dateutil - # tacacs-plus slack-sdk==3.27.0 # via -r /awx_devel/requirements/requirements.in smmap==5.0.1 @@ -510,7 +509,6 @@ sqlparse==0.4.4 # via # -r /awx_devel/requirements/requirements.in # django -tacacs-plus==1.0 # via -r /awx_devel/requirements/requirements.in tempora==5.5.1 # via diff --git a/tools/docker-compose/README.md b/tools/docker-compose/README.md index 77e10233bcdb..df9187762e3b 100644 --- a/tools/docker-compose/README.md +++ b/tools/docker-compose/README.md @@ -273,7 +273,6 @@ $ make docker-compose - [Start with Minikube](#start-with-minikube) - [SAML and OIDC Integration](#saml-and-oidc-integration) - [Splunk Integration](#splunk-integration) -- [tacacs+ Integration](#tacacs+-integration) ### Start a Shell @@ -465,30 +464,6 @@ ansible-playbook tools/docker-compose/ansible/plumb_splunk.yml Once the playbook is done running Splunk should now be setup in your development environment. You can log into the admin console (see above for username/password) and click on "Searching and Reporting" in the left hand navigation. In the search box enter `source="http:tower_logging_collections"` and click search. -### - tacacs+ Integration - -tacacs+ is an networking protocol that provides external authentication which can be used with AWX. This section describes how to build a reference tacacs+ instance and plumb it with your AWX for testing purposes. - -First, be sure that you have the awx.awx collection installed by running `make install_collection`. - -Anytime you want to run a tacacs+ instance alongside AWX we can start docker-compose with the TACACS option to get a containerized instance with the command: -```bash -TACACS=true make docker-compose -``` - -Once the containers come up a new port (49) should be exposed and the tacacs+ server should be running on those ports. - -Now we are ready to configure and plumb tacacs+ with AWX. To do this we have provided a playbook which will: -* Backup and configure the tacacsplus adapter in AWX. NOTE: this will back up your existing settings but the password fields can not be backed up through the API, you need a DB backup to recover this. - -```bash -export CONTROLLER_USERNAME= -export CONTROLLER_PASSWORD= -ansible-playbook tools/docker-compose/ansible/plumb_tacacs.yml -``` - -Once the playbook is done running tacacs+ should now be setup in your development environment. This server has the accounts listed on https://hub.docker.com/r/dchidell/docker-tacacs - ### HashiVault Integration Run a HashiVault container alongside of AWX. diff --git a/tools/docker-compose/ansible/plumb_tacacs.yml b/tools/docker-compose/ansible/plumb_tacacs.yml deleted file mode 100644 index b18a72284a3e..000000000000 --- a/tools/docker-compose/ansible/plumb_tacacs.yml +++ /dev/null @@ -1,32 +0,0 @@ ---- -- name: Plumb a tacacs+ instance - hosts: localhost - connection: local - gather_facts: False - vars: - awx_host: "https://localhost:8043" - tasks: - - name: Load existing and new tacacs+ settings - ansible.builtin.set_fact: - existing_tacacs: "{{ lookup('awx.awx.controller_api', 'settings/tacacsplus', host=awx_host, verify_ssl=false) }}" - new_tacacs: "{{ lookup('template', 'tacacsplus_settings.json.j2') }}" - - - name: Display existing tacacs+ configuration - ansible.builtin.debug: - msg: - - "Here is your existing tacacsplus configuration for reference:" - - "{{ existing_tacacs }}" - - - ansible.builtin.pause: - prompt: "Continuing to run this will replace your existing tacacs settings (displayed above). They will all be captured. Be sure that is backed up before continuing" - - - name: Write out the existing content - ansible.builtin.copy: - dest: "../_sources/existing_tacacsplus_adapter_settings.json" - content: "{{ existing_tacacs }}" - - - name: Configure AWX tacacs+ adapter - awx.awx.settings: - settings: "{{ new_tacacs }}" - controller_host: "{{ awx_host }}" - validate_certs: False diff --git a/tools/docker-compose/ansible/roles/sources/templates/docker-compose.yml.j2 b/tools/docker-compose/ansible/roles/sources/templates/docker-compose.yml.j2 index 80f075ab4140..e0db3a5c6393 100644 --- a/tools/docker-compose/ansible/roles/sources/templates/docker-compose.yml.j2 +++ b/tools/docker-compose/ansible/roles/sources/templates/docker-compose.yml.j2 @@ -188,14 +188,6 @@ services: - "grafana_storage:/var/lib/grafana:rw" depends_on: - prometheus -{% endif %} -{% if enable_tacacs|bool %} - tacacs: - image: dchidell/docker-tacacs - container_name: tools_tacacs_1 - hostname: tacacs - ports: - - "49:49" {% endif %} # A useful container that simply passes through log messages to the console # helpful for testing awx/tower logging diff --git a/tools/docker-compose/ansible/templates/tacacsplus_settings.json.j2 b/tools/docker-compose/ansible/templates/tacacsplus_settings.json.j2 deleted file mode 100644 index fe9dd8c39112..000000000000 --- a/tools/docker-compose/ansible/templates/tacacsplus_settings.json.j2 +++ /dev/null @@ -1,7 +0,0 @@ -{ - "TACACSPLUS_HOST": "tacacs", - "TACACSPLUS_PORT": 49, - "TACACSPLUS_SECRET": "ciscotacacskey", - "TACACSPLUS_SESSION_TIMEOUT": 5, - "TACACSPLUS_AUTH_PROTOCOL": "ascii" -}