mirror of
https://github.com/freeipa/ansible-freeipa.git
synced 2026-03-26 21:33:05 +00:00
New idp management module
There is a new idp management module placed in the plugins folder:
plugins/modules/ipaidp.py
The idp module allows to ensure presence or absence of external Identity
Providers.
Here is the documentation for the module:
README-idp.md
New idp example playbooks:
playbooks/idp/idp-present.yml
playbooks/idp/idp-absent.yml
New tests for the module:
tests/idp/test_idp.yml
tests/idp/test_idp_client_context.yml
This commit is contained in:
192
README-idp.md
Normal file
192
README-idp.md
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
Idp module
|
||||||
|
============
|
||||||
|
|
||||||
|
Description
|
||||||
|
-----------
|
||||||
|
|
||||||
|
The idp module allows to ensure presence and absence of idps.
|
||||||
|
|
||||||
|
Features
|
||||||
|
--------
|
||||||
|
|
||||||
|
* Idp management
|
||||||
|
|
||||||
|
|
||||||
|
Supported FreeIPA Versions
|
||||||
|
--------------------------
|
||||||
|
|
||||||
|
FreeIPA versions 4.4.0 and up are supported by the ipaidp module.
|
||||||
|
|
||||||
|
|
||||||
|
Requirements
|
||||||
|
------------
|
||||||
|
|
||||||
|
**Controller**
|
||||||
|
* Ansible version: 2.13
|
||||||
|
|
||||||
|
**Node**
|
||||||
|
* Supported FreeIPA version (see above)
|
||||||
|
|
||||||
|
|
||||||
|
Usage
|
||||||
|
=====
|
||||||
|
|
||||||
|
Example inventory file
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[ipaserver]
|
||||||
|
ipaserver.test.local
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
Example playbook to make sure keycloak idp my-keycloak-idp is present:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
- name: Playbook to manage IPA idp.
|
||||||
|
hosts: ipaserver
|
||||||
|
become: false
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
- name: Ensure keycloak idp my-keycloak-idp is present
|
||||||
|
ipaidp:
|
||||||
|
ipaadmin_password: SomeADMINpassword
|
||||||
|
name: my-keycloak-idp
|
||||||
|
provider: keycloak
|
||||||
|
organization: main
|
||||||
|
base_url: keycloak.idm.example.com:8443/auth
|
||||||
|
client_id: my-client-id
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
Example playbook to make sure keycloak idp my-keycloak-idp is absent:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
- name: Playbook to manage IPA idp.
|
||||||
|
hosts: ipaserver
|
||||||
|
become: false
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
- name: Ensure keycloak idp my-keycloak-idp is absent
|
||||||
|
ipaidp:
|
||||||
|
ipaadmin_password: SomeADMINpassword
|
||||||
|
name: my-keycloak-idp
|
||||||
|
delete_continue: true
|
||||||
|
state: absent
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
Example playbook to make sure github idp my-github-idp is present:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
- name: Playbook to manage IPA idp.
|
||||||
|
hosts: ipaserver
|
||||||
|
become: false
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
- name: Ensure github idp my-github-idp is present
|
||||||
|
ipaidp:
|
||||||
|
ipaadmin_password: SomeADMINpassword
|
||||||
|
name: my-github-idp
|
||||||
|
provider: github
|
||||||
|
client_id: my-github-client-id
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
Example playbook to make sure google idp my-google-idp is present using provider defaults without specifying provider:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
- name: Playbook to manage IPA idp.
|
||||||
|
hosts: ipaserver
|
||||||
|
become: false
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
- name: Ensure google idp my-google-idp is present using provider defaults without specifying provider
|
||||||
|
ipaidp:
|
||||||
|
ipaadmin_password: SomeADMINpassword
|
||||||
|
name: my-google-idp
|
||||||
|
auth_uri: https://accounts.google.com/o/oauth2/auth
|
||||||
|
dev_auth_uri: https://oauth2.googleapis.com/device/code
|
||||||
|
token_uri: https://oauth2.googleapis.com/token
|
||||||
|
keys_uri: https://www.googleapis.com/oauth2/v3/certs
|
||||||
|
userinfo_uri: https://openidconnect.googleapis.com/v1/userinfo
|
||||||
|
client_id: my-google-client-id
|
||||||
|
scope: "openid email"
|
||||||
|
idp_user_id: email
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
Example playbook to make sure google idp my-google-idp is present using provider:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
- name: Playbook to manage IPA idp.
|
||||||
|
hosts: ipaserver
|
||||||
|
become: false
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
- name: Ensure google idp my-google-idp is present using provider
|
||||||
|
ipaidp:
|
||||||
|
ipaadmin_password: SomeADMINpassword
|
||||||
|
name: my-google-idp
|
||||||
|
provider: google
|
||||||
|
client_id: my-google-client-id
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
Example playbook to make sure idps my-keycloak-idp, my-github-idp and my-google-idp are absent:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
- name: Playbook to manage IPA idp.
|
||||||
|
hosts: ipaserver
|
||||||
|
become: false
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
- name: Ensure idps my-keycloak-idp, my-github-idp and my-google-idp are absent
|
||||||
|
ipaidp:
|
||||||
|
ipaadmin_password: SomeADMINpassword
|
||||||
|
name:
|
||||||
|
- my-keycloak-idp
|
||||||
|
- my-github-idp
|
||||||
|
- my-google-idp
|
||||||
|
delete_continue: true
|
||||||
|
state: absent
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
Variables
|
||||||
|
---------
|
||||||
|
|
||||||
|
Variable | Description | Required
|
||||||
|
-------- | ----------- | --------
|
||||||
|
`ipaadmin_principal` | The admin principal is a string and defaults to `admin` | no
|
||||||
|
`ipaadmin_password` | The admin password is a string and is required if there is no admin ticket available on the node | no
|
||||||
|
`ipaapi_context` | The context in which the module will execute. Executing in a server context is preferred. If not provided context will be determined by the execution environment. Valid values are `server` and `client`. | no
|
||||||
|
`ipaapi_ldap_cache` | Use LDAP cache for IPA connection. The bool setting defaults to true. (bool) | false
|
||||||
|
`name` \| `cn` | The list of idp name strings. | yes
|
||||||
|
auth_uri \| ipaidpauthendpoint | OAuth 2.0 authorization endpoint string. | no
|
||||||
|
dev_auth_uri \| ipaidpdevauthendpoint | Device authorization endpoint string. | no
|
||||||
|
token_uri \| ipaidptokenendpoint | Token endpoint string. | no
|
||||||
|
userinfo_uri \| ipaidpuserinfoendpoint | User information endpoint string. | no
|
||||||
|
keys_uri \| ipaidpkeysendpoint | JWKS endpoint string. | no
|
||||||
|
issuer_url \| ipaidpissuerurl | The Identity Provider OIDC URL string. | no
|
||||||
|
client_id \| ipaidpclientid | OAuth 2.0 client identifier string. | no
|
||||||
|
secret \| ipaidpclientsecret | OAuth 2.0 client secret string. | no
|
||||||
|
scope \| ipaidpscope | OAuth 2.0 scope string. Multiple scopes separated by space. | no
|
||||||
|
idp_user_id \| ipaidpsub | Attribute string for user identity in OAuth 2.0 userinfo. | no
|
||||||
|
provider \| ipaidpprovider | Pre-defined template string. This provides the provider defaults, which can be overridden with the other IdP options. Choices: ["google","github","microsoft","okta","keycloak"] | no
|
||||||
|
organization \| ipaidporg | Organization ID string or Realm name for IdP provider templates. | no
|
||||||
|
base_url \| ipaidpbaseurl | Base URL string for IdP provider templates. | no
|
||||||
|
rename \| new_name | New name for the Identity Provider server object. Only with `state: renamed`. | no
|
||||||
|
delete_continue \| continue | Continuous mode. Don't stop on errors. Valid only if `state` is `absent`. | no
|
||||||
|
`state` | The state to ensure. It can be one of `present`, `absent`, `renamed`, default: `present`. | no
|
||||||
|
|
||||||
|
|
||||||
|
Authors
|
||||||
|
=======
|
||||||
|
|
||||||
|
Thomas Woerner
|
||||||
@@ -32,6 +32,7 @@ Features
|
|||||||
* Modules for hostgroup management
|
* Modules for hostgroup management
|
||||||
* Modules for idoverridegroup management
|
* Modules for idoverridegroup management
|
||||||
* Modules for idoverrideuser management
|
* Modules for idoverrideuser management
|
||||||
|
* Modules for idp management
|
||||||
* Modules for idrange management
|
* Modules for idrange management
|
||||||
* Modules for idview management
|
* Modules for idview management
|
||||||
* Modules for location management
|
* Modules for location management
|
||||||
@@ -445,6 +446,7 @@ Modules in plugin/modules
|
|||||||
* [ipahostgroup](README-hostgroup.md)
|
* [ipahostgroup](README-hostgroup.md)
|
||||||
* [idoverridegroup](README-idoverridegroup.md)
|
* [idoverridegroup](README-idoverridegroup.md)
|
||||||
* [idoverrideuser](README-idoverrideuser.md)
|
* [idoverrideuser](README-idoverrideuser.md)
|
||||||
|
* [idp](README-idp.md)
|
||||||
* [idrange](README-idrange.md)
|
* [idrange](README-idrange.md)
|
||||||
* [idview](README-idview.md)
|
* [idview](README-idview.md)
|
||||||
* [ipalocation](README-location.md)
|
* [ipalocation](README-location.md)
|
||||||
|
|||||||
11
playbooks/idp/idp-absent.yml
Normal file
11
playbooks/idp/idp-absent.yml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
- name: Idp absent example
|
||||||
|
hosts: ipaserver
|
||||||
|
become: no
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
- name: Ensure github idp my-github-idp is absent
|
||||||
|
ipaidp:
|
||||||
|
ipaadmin_password: SomeADMINpassword
|
||||||
|
name: my-github-idp
|
||||||
|
state: absent
|
||||||
12
playbooks/idp/idp-present.yml
Normal file
12
playbooks/idp/idp-present.yml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
- name: Idp present example
|
||||||
|
hosts: ipaserver
|
||||||
|
become: no
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
- name: Ensure github idp my-github-idp is present
|
||||||
|
ipaidp:
|
||||||
|
ipaadmin_password: SomeADMINpassword
|
||||||
|
name: my-github-idp
|
||||||
|
provider: github
|
||||||
|
client_id: my-github-client-id
|
||||||
@@ -30,7 +30,7 @@ __all__ = ["gssapi", "netaddr", "api", "ipalib_errors", "Env",
|
|||||||
"kinit_password", "kinit_keytab", "run", "DN", "VERSION",
|
"kinit_password", "kinit_keytab", "run", "DN", "VERSION",
|
||||||
"paths", "tasks", "get_credentials_if_valid", "Encoding",
|
"paths", "tasks", "get_credentials_if_valid", "Encoding",
|
||||||
"DNSName", "getargspec", "certificate_loader",
|
"DNSName", "getargspec", "certificate_loader",
|
||||||
"write_certificate_list", "boolean"]
|
"write_certificate_list", "boolean", "template_str"]
|
||||||
|
|
||||||
import os
|
import os
|
||||||
# ansible-freeipa requires locale to be C, IPA requires utf-8.
|
# ansible-freeipa requires locale to be C, IPA requires utf-8.
|
||||||
@@ -90,6 +90,7 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
from ipapython.ipautil import kinit_password, kinit_keytab
|
from ipapython.ipautil import kinit_password, kinit_keytab
|
||||||
from ipapython.ipautil import run
|
from ipapython.ipautil import run
|
||||||
|
from ipapython.ipautil import template_str
|
||||||
from ipapython.dn import DN
|
from ipapython.dn import DN
|
||||||
from ipapython.version import VERSION
|
from ipapython.version import VERSION
|
||||||
from ipaplatform.paths import paths
|
from ipaplatform.paths import paths
|
||||||
|
|||||||
544
plugins/modules/ipaidp.py
Normal file
544
plugins/modules/ipaidp.py
Normal file
@@ -0,0 +1,544 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# Authors:
|
||||||
|
# Thomas Woerner <twoerner@redhat.com>
|
||||||
|
#
|
||||||
|
# Copyright (C) 2023 Red Hat
|
||||||
|
# see file 'COPYING' for use and warranty information
|
||||||
|
#
|
||||||
|
# This program is free software; you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from __future__ import (absolute_import, division, print_function)
|
||||||
|
|
||||||
|
__metaclass__ = type
|
||||||
|
|
||||||
|
ANSIBLE_METADATA = {
|
||||||
|
"metadata_version": "1.0",
|
||||||
|
"supported_by": "community",
|
||||||
|
"status": ["preview"],
|
||||||
|
}
|
||||||
|
|
||||||
|
DOCUMENTATION = """
|
||||||
|
---
|
||||||
|
module: ipaidp
|
||||||
|
short_description: Manage FreeIPA idp
|
||||||
|
description: Manage FreeIPA idp
|
||||||
|
extends_documentation_fragment:
|
||||||
|
- ipamodule_base_docs
|
||||||
|
options:
|
||||||
|
name:
|
||||||
|
description: The list of idp name strings.
|
||||||
|
required: true
|
||||||
|
type: list
|
||||||
|
elements: str
|
||||||
|
aliases: ["cn"]
|
||||||
|
auth_uri:
|
||||||
|
description: OAuth 2.0 authorization endpoint
|
||||||
|
required: false
|
||||||
|
type: str
|
||||||
|
aliases: ["ipaidpauthendpoint"]
|
||||||
|
dev_auth_uri:
|
||||||
|
description: Device authorization endpoint
|
||||||
|
required: false
|
||||||
|
type: str
|
||||||
|
aliases: ["ipaidpdevauthendpoint"]
|
||||||
|
token_uri:
|
||||||
|
description: Token endpoint
|
||||||
|
required: false
|
||||||
|
type: str
|
||||||
|
aliases: ["ipaidptokenendpoint"]
|
||||||
|
userinfo_uri:
|
||||||
|
description: User information endpoint
|
||||||
|
required: false
|
||||||
|
type: str
|
||||||
|
aliases: ["ipaidpuserinfoendpoint"]
|
||||||
|
keys_uri:
|
||||||
|
description: JWKS endpoint
|
||||||
|
required: false
|
||||||
|
type: str
|
||||||
|
aliases: ["ipaidpkeysendpoint"]
|
||||||
|
issuer_url:
|
||||||
|
description: The Identity Provider OIDC URL
|
||||||
|
required: false
|
||||||
|
type: str
|
||||||
|
aliases: ["ipaidpissuerurl"]
|
||||||
|
client_id:
|
||||||
|
description: OAuth 2.0 client identifier
|
||||||
|
required: false
|
||||||
|
type: str
|
||||||
|
aliases: ["ipaidpclientid"]
|
||||||
|
secret:
|
||||||
|
description: OAuth 2.0 client secret
|
||||||
|
required: false
|
||||||
|
type: str
|
||||||
|
no_log: true
|
||||||
|
aliases: ["ipaidpclientsecret"]
|
||||||
|
scope:
|
||||||
|
description: OAuth 2.0 scope. Multiple scopes separated by space
|
||||||
|
required: false
|
||||||
|
type: str
|
||||||
|
aliases: ["ipaidpscope"]
|
||||||
|
idp_user_id:
|
||||||
|
description: Attribute for user identity in OAuth 2.0 userinfo
|
||||||
|
required: false
|
||||||
|
type: str
|
||||||
|
aliases: ["ipaidpsub"]
|
||||||
|
provider:
|
||||||
|
description: |
|
||||||
|
Pre-defined template string. This provides the provider defaults, which
|
||||||
|
can be overridden with the other IdP options.
|
||||||
|
required: false
|
||||||
|
type: str
|
||||||
|
choices: ["google","github","microsoft","okta","keycloak"]
|
||||||
|
aliases: ["ipaidpprovider"]
|
||||||
|
organization:
|
||||||
|
description: Organization ID or Realm name for IdP provider templates
|
||||||
|
required: false
|
||||||
|
type: str
|
||||||
|
aliases: ["ipaidporg"]
|
||||||
|
base_url:
|
||||||
|
description: Base URL for IdP provider templates
|
||||||
|
required: false
|
||||||
|
type: str
|
||||||
|
aliases: ["ipaidpbaseurl"]
|
||||||
|
rename:
|
||||||
|
description: |
|
||||||
|
New name the Identity Provider server object. Only with state: renamed.
|
||||||
|
required: false
|
||||||
|
type: str
|
||||||
|
aliases: ["new_name"]
|
||||||
|
delete_continue:
|
||||||
|
description:
|
||||||
|
Continuous mode. Don't stop on errors. Valid only if `state` is `absent`.
|
||||||
|
required: false
|
||||||
|
type: bool
|
||||||
|
aliases: ["continue"]
|
||||||
|
state:
|
||||||
|
description: The state to ensure.
|
||||||
|
choices: ["present", "absent", "renamed"]
|
||||||
|
default: present
|
||||||
|
type: str
|
||||||
|
author:
|
||||||
|
- Thomas Woerner (@t-woerner)
|
||||||
|
"""
|
||||||
|
|
||||||
|
EXAMPLES = """
|
||||||
|
# Ensure keycloak idp my-keycloak-idp is present
|
||||||
|
- ipaidp:
|
||||||
|
ipaadmin_password: SomeADMINpassword
|
||||||
|
name: my-keycloak-idp
|
||||||
|
provider: keycloak
|
||||||
|
organization: main
|
||||||
|
base_url: keycloak.idm.example.com:8443/auth
|
||||||
|
client_id: my-client-id
|
||||||
|
|
||||||
|
# Ensure google idp my-google-idp is present
|
||||||
|
- ipaidp:
|
||||||
|
ipaadmin_password: SomeADMINpassword
|
||||||
|
name: my-google-idp
|
||||||
|
auth_uri: https://accounts.google.com/o/oauth2/auth
|
||||||
|
dev_auth_uri: https://oauth2.googleapis.com/device/code
|
||||||
|
token_uri: https://oauth2.googleapis.com/token
|
||||||
|
userinfo_uri: https://openidconnect.googleapis.com/v1/userinfo
|
||||||
|
client_id: my-client-id
|
||||||
|
scope: "openid email"
|
||||||
|
idp_user_id: email
|
||||||
|
|
||||||
|
# Ensure google idp my-google-idp is present without using provider
|
||||||
|
- ipaidp:
|
||||||
|
ipaadmin_password: SomeADMINpassword
|
||||||
|
name: my-google-idp
|
||||||
|
provider: google
|
||||||
|
client_id: my-google-client-id
|
||||||
|
|
||||||
|
# Ensure keycloak idp my-keycloak-idp is absent
|
||||||
|
- ipaidp:
|
||||||
|
ipaadmin_password: SomeADMINpassword
|
||||||
|
name: my-keycloak-idp
|
||||||
|
delete_continue: true
|
||||||
|
state: absent
|
||||||
|
|
||||||
|
# Ensure idps my-keycloak-idp, my-github-idp and my-google-idp are absent
|
||||||
|
- ipaidp:
|
||||||
|
ipaadmin_password: SomeADMINpassword
|
||||||
|
name:
|
||||||
|
- my-keycloak-idp
|
||||||
|
- my-github-idp
|
||||||
|
- my-google-idp
|
||||||
|
delete_continue: true
|
||||||
|
state: absent
|
||||||
|
"""
|
||||||
|
|
||||||
|
RETURN = """
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
from ansible.module_utils.ansible_freeipa_module import \
|
||||||
|
IPAAnsibleModule, compare_args_ipa, template_str
|
||||||
|
from ansible.module_utils import six
|
||||||
|
from copy import deepcopy
|
||||||
|
import string
|
||||||
|
from itertools import chain
|
||||||
|
|
||||||
|
if six.PY3:
|
||||||
|
unicode = str
|
||||||
|
|
||||||
|
# Copy from FreeIPA ipaserver/plugins/idp.py
|
||||||
|
idp_providers = {
|
||||||
|
'google': {
|
||||||
|
'ipaidpauthendpoint':
|
||||||
|
'https://accounts.google.com/o/oauth2/auth',
|
||||||
|
'ipaidpdevauthendpoint':
|
||||||
|
'https://oauth2.googleapis.com/device/code',
|
||||||
|
'ipaidptokenendpoint':
|
||||||
|
'https://oauth2.googleapis.com/token',
|
||||||
|
'ipaidpuserinfoendpoint':
|
||||||
|
'https://openidconnect.googleapis.com/v1/userinfo',
|
||||||
|
'ipaidpkeysendpoint':
|
||||||
|
'https://www.googleapis.com/oauth2/v3/certs',
|
||||||
|
'ipaidpscope': 'openid email',
|
||||||
|
'ipaidpsub': 'email'},
|
||||||
|
'github': {
|
||||||
|
'ipaidpauthendpoint':
|
||||||
|
'https://github.com/login/oauth/authorize',
|
||||||
|
'ipaidpdevauthendpoint':
|
||||||
|
'https://github.com/login/device/code',
|
||||||
|
'ipaidptokenendpoint':
|
||||||
|
'https://github.com/login/oauth/access_token',
|
||||||
|
'ipaidpuserinfoendpoint':
|
||||||
|
'https://api.github.com/user',
|
||||||
|
'ipaidpscope': 'user',
|
||||||
|
'ipaidpsub': 'login'},
|
||||||
|
'microsoft': {
|
||||||
|
'ipaidpauthendpoint':
|
||||||
|
'https://login.microsoftonline.com/${ipaidporg}/oauth2/v2.0/'
|
||||||
|
'authorize',
|
||||||
|
'ipaidpdevauthendpoint':
|
||||||
|
'https://login.microsoftonline.com/${ipaidporg}/oauth2/v2.0/'
|
||||||
|
'devicecode',
|
||||||
|
'ipaidptokenendpoint':
|
||||||
|
'https://login.microsoftonline.com/${ipaidporg}/oauth2/v2.0/'
|
||||||
|
'token',
|
||||||
|
'ipaidpuserinfoendpoint':
|
||||||
|
'https://graph.microsoft.com/oidc/userinfo',
|
||||||
|
'ipaidpkeysendpoint':
|
||||||
|
'https://login.microsoftonline.com/common/discovery/v2.0/keys',
|
||||||
|
'ipaidpscope': 'openid email',
|
||||||
|
'ipaidpsub': 'email',
|
||||||
|
},
|
||||||
|
'okta': {
|
||||||
|
'ipaidpauthendpoint':
|
||||||
|
'https://${ipaidpbaseurl}/oauth2/v1/authorize',
|
||||||
|
'ipaidpdevauthendpoint':
|
||||||
|
'https://${ipaidpbaseurl}/oauth2/v1/device/authorize',
|
||||||
|
'ipaidptokenendpoint':
|
||||||
|
'https://${ipaidpbaseurl}/oauth2/v1/token',
|
||||||
|
'ipaidpuserinfoendpoint':
|
||||||
|
'https://${ipaidpbaseurl}/oauth2/v1/userinfo',
|
||||||
|
'ipaidpscope': 'openid email',
|
||||||
|
'ipaidpsub': 'email'},
|
||||||
|
'keycloak': {
|
||||||
|
'ipaidpauthendpoint':
|
||||||
|
'https://${ipaidpbaseurl}/realms/${ipaidporg}/protocol/'
|
||||||
|
'openid-connect/auth',
|
||||||
|
'ipaidpdevauthendpoint':
|
||||||
|
'https://${ipaidpbaseurl}/realms/${ipaidporg}/protocol/'
|
||||||
|
'openid-connect/auth/device',
|
||||||
|
'ipaidptokenendpoint':
|
||||||
|
'https://${ipaidpbaseurl}/realms/${ipaidporg}/protocol/'
|
||||||
|
'openid-connect/token',
|
||||||
|
'ipaidpuserinfoendpoint':
|
||||||
|
'https://${ipaidpbaseurl}/realms/${ipaidporg}/protocol/'
|
||||||
|
'openid-connect/userinfo',
|
||||||
|
'ipaidpscope': 'openid email',
|
||||||
|
'ipaidpsub': 'email'},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def find_idp(module, name):
|
||||||
|
"""Find if a idp with the given name already exist."""
|
||||||
|
try:
|
||||||
|
_result = module.ipa_command("idp_show", name, {"all": True})
|
||||||
|
except Exception: # pylint: disable=broad-except
|
||||||
|
# An exception is raised if idp name is not found.
|
||||||
|
return None
|
||||||
|
|
||||||
|
return _result["result"]
|
||||||
|
|
||||||
|
|
||||||
|
def gen_args(auth_uri, dev_auth_uri, token_uri, userinfo_uri, keys_uri,
|
||||||
|
issuer_url, client_id, secret, scope, idp_user_id, organization,
|
||||||
|
base_url):
|
||||||
|
_args = {}
|
||||||
|
if auth_uri is not None:
|
||||||
|
_args["ipaidpauthendpoint"] = auth_uri
|
||||||
|
if dev_auth_uri is not None:
|
||||||
|
_args["ipaidpdevauthendpoint"] = dev_auth_uri
|
||||||
|
if token_uri is not None:
|
||||||
|
_args["ipaidptokenendpoint"] = token_uri
|
||||||
|
if userinfo_uri is not None:
|
||||||
|
_args["ipaidpuserinfoendpoint"] = userinfo_uri
|
||||||
|
if keys_uri is not None:
|
||||||
|
_args["ipaidpkeysendpoint"] = keys_uri
|
||||||
|
if issuer_url is not None:
|
||||||
|
_args["ipaidpissuerurl"] = issuer_url
|
||||||
|
if client_id is not None:
|
||||||
|
_args["ipaidpclientid"] = client_id
|
||||||
|
if secret is not None:
|
||||||
|
_args["ipaidpclientsecret"] = secret
|
||||||
|
if scope is not None:
|
||||||
|
_args["ipaidpscope"] = scope
|
||||||
|
if idp_user_id is not None:
|
||||||
|
_args["ipaidpsub"] = idp_user_id
|
||||||
|
if organization is not None:
|
||||||
|
_args["ipaidporg"] = organization
|
||||||
|
if base_url is not None:
|
||||||
|
_args["ipaidpbaseurl"] = base_url
|
||||||
|
return _args
|
||||||
|
|
||||||
|
|
||||||
|
# Copied and adapted from FreeIPA ipaserver/plugins/idp.py
|
||||||
|
def convert_provider_to_endpoints(module, _args, provider):
|
||||||
|
"""Convert provider option to auth-uri and token-uri,.."""
|
||||||
|
if provider not in idp_providers:
|
||||||
|
module.fail_json(msg="Provider '%s' is unknown" % provider)
|
||||||
|
|
||||||
|
# For each string in the template check if a variable
|
||||||
|
# is required, it is provided as an option
|
||||||
|
points = deepcopy(idp_providers[provider])
|
||||||
|
_r = string.Template.pattern
|
||||||
|
for (_k, _v) in points.items():
|
||||||
|
# build list of variables to be replaced
|
||||||
|
subs = list(chain.from_iterable(
|
||||||
|
(filter(None, _s) for _s in _r.findall(_v))))
|
||||||
|
if subs:
|
||||||
|
for _s in subs:
|
||||||
|
if _s not in _args:
|
||||||
|
module.fail_json(msg="Parameter '%s' is missing" % _s)
|
||||||
|
points[_k] = template_str(_v, _args)
|
||||||
|
elif _k in _args:
|
||||||
|
points[_k] = _args[_k]
|
||||||
|
|
||||||
|
_args.update(points)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ansible_module = IPAAnsibleModule(
|
||||||
|
argument_spec=dict(
|
||||||
|
# general
|
||||||
|
name=dict(type="list", elements="str", required=True,
|
||||||
|
aliases=["cn"]),
|
||||||
|
# present
|
||||||
|
auth_uri=dict(required=False, type="str", default=None,
|
||||||
|
aliases=["ipaidpauthendpoint"]),
|
||||||
|
dev_auth_uri=dict(required=False, type="str", default=None,
|
||||||
|
aliases=["ipaidpdevauthendpoint"]),
|
||||||
|
token_uri=dict(required=False, type="str", default=None,
|
||||||
|
aliases=["ipaidptokenendpoint"]),
|
||||||
|
userinfo_uri=dict(required=False, type="str", default=None,
|
||||||
|
aliases=["ipaidpuserinfoendpoint"]),
|
||||||
|
keys_uri=dict(required=False, type="str", default=None,
|
||||||
|
aliases=["ipaidpkeysendpoint"]),
|
||||||
|
issuer_url=dict(required=False, type="str", default=None,
|
||||||
|
aliases=["ipaidpissuerurl"]),
|
||||||
|
client_id=dict(required=False, type="str", default=None,
|
||||||
|
aliases=["ipaidpclientid"]),
|
||||||
|
secret=dict(required=False, type="str", default=None,
|
||||||
|
aliases=["ipaidpclientsecret"], no_log=True),
|
||||||
|
scope=dict(required=False, type="str", default=None,
|
||||||
|
aliases=["ipaidpscope"]),
|
||||||
|
idp_user_id=dict(required=False, type="str", default=None,
|
||||||
|
aliases=["ipaidpsub"]),
|
||||||
|
provider=dict(required=False, type="str", default=None,
|
||||||
|
aliases=["ipaidpprovider"],
|
||||||
|
choices=["google", "github", "microsoft", "okta",
|
||||||
|
"keycloak"]),
|
||||||
|
organization=dict(required=False, type="str", default=None,
|
||||||
|
aliases=["ipaidporg"]),
|
||||||
|
base_url=dict(required=False, type="str", default=None,
|
||||||
|
aliases=["ipaidpbaseurl"]),
|
||||||
|
rename=dict(required=False, type="str", default=None,
|
||||||
|
aliases=["new_name"]),
|
||||||
|
delete_continue=dict(required=False, type="bool", default=None,
|
||||||
|
aliases=['continue']),
|
||||||
|
# state
|
||||||
|
state=dict(type="str", default="present",
|
||||||
|
choices=["present", "absent", "renamed"]),
|
||||||
|
),
|
||||||
|
supports_check_mode=True,
|
||||||
|
# mutually_exclusive=[],
|
||||||
|
# required_one_of=[]
|
||||||
|
)
|
||||||
|
|
||||||
|
ansible_module._ansible_debug = True
|
||||||
|
|
||||||
|
# Get parameters
|
||||||
|
|
||||||
|
# general
|
||||||
|
names = ansible_module.params_get("name")
|
||||||
|
|
||||||
|
# present
|
||||||
|
auth_uri = ansible_module.params_get("auth_uri")
|
||||||
|
dev_auth_uri = ansible_module.params_get("dev_auth_uri")
|
||||||
|
token_uri = ansible_module.params_get("token_uri")
|
||||||
|
userinfo_uri = ansible_module.params_get("userinfo_uri")
|
||||||
|
keys_uri = ansible_module.params_get("keys_uri")
|
||||||
|
issuer_url = ansible_module.params_get("issuer_url")
|
||||||
|
client_id = ansible_module.params_get("client_id")
|
||||||
|
secret = ansible_module.params_get("secret")
|
||||||
|
scope = ansible_module.params_get("scope")
|
||||||
|
idp_user_id = ansible_module.params_get("idp_user_id")
|
||||||
|
provider = ansible_module.params_get("provider")
|
||||||
|
organization = ansible_module.params_get("organization")
|
||||||
|
base_url = ansible_module.params_get("base_url")
|
||||||
|
rename = ansible_module.params_get("rename")
|
||||||
|
|
||||||
|
delete_continue = ansible_module.params_get("delete_continue")
|
||||||
|
|
||||||
|
# state
|
||||||
|
state = ansible_module.params_get("state")
|
||||||
|
|
||||||
|
# Check parameters
|
||||||
|
|
||||||
|
invalid = []
|
||||||
|
|
||||||
|
if state == "present":
|
||||||
|
if len(names) != 1:
|
||||||
|
ansible_module.fail_json(
|
||||||
|
msg="Only one idp can be added at a time.")
|
||||||
|
if provider:
|
||||||
|
if any([auth_uri, dev_auth_uri, token_uri, userinfo_uri,
|
||||||
|
keys_uri]):
|
||||||
|
ansible_module.fail_json(
|
||||||
|
msg="Cannot specify both individual endpoints and IdP "
|
||||||
|
"provider")
|
||||||
|
if provider not in idp_providers:
|
||||||
|
ansible_module.fail_json(
|
||||||
|
msg="Provider '%s' is unknown" % provider)
|
||||||
|
else:
|
||||||
|
if not auth_uri:
|
||||||
|
ansible_module.fail_json(
|
||||||
|
msg="Parameter '%s' is missing" % "auth_uri")
|
||||||
|
if not dev_auth_uri:
|
||||||
|
ansible_module.fail_json(
|
||||||
|
msg="Parameter '%s' is missing" % "dev_auth_uri")
|
||||||
|
if not token_uri:
|
||||||
|
ansible_module.fail_json(
|
||||||
|
msg="Parameter '%s' is missing" % "token_uri")
|
||||||
|
if not userinfo_uri:
|
||||||
|
ansible_module.fail_json(
|
||||||
|
msg="Parameter '%s' is missing" % "userinfo_uri")
|
||||||
|
invalid = ["rename", "delete_continue"]
|
||||||
|
else:
|
||||||
|
# state renamed and absent
|
||||||
|
invalid = ["auth_uri", "dev_auth_uri", "token_uri", "userinfo_uri",
|
||||||
|
"keys_uri", "issuer_url", "client_id", "secret", "scope",
|
||||||
|
"idp_user_id", "provider", "organization", "base_url"]
|
||||||
|
|
||||||
|
if state == "renamed":
|
||||||
|
if len(names) != 1:
|
||||||
|
ansible_module.fail_json(
|
||||||
|
msg="Only one permission can be renamed at a time.")
|
||||||
|
invalid += ["delete_continue"]
|
||||||
|
|
||||||
|
if state == "absent":
|
||||||
|
if len(names) < 1:
|
||||||
|
ansible_module.fail_json(msg="No name given.")
|
||||||
|
invalid += ["rename"]
|
||||||
|
|
||||||
|
ansible_module.params_fail_used_invalid(invalid, state)
|
||||||
|
|
||||||
|
# Init
|
||||||
|
|
||||||
|
changed = False
|
||||||
|
exit_args = {}
|
||||||
|
|
||||||
|
# Connect to IPA API
|
||||||
|
with ansible_module.ipa_connect():
|
||||||
|
|
||||||
|
if not ansible_module.ipa_command_exists("idp_add"):
|
||||||
|
ansible_module.fail_json(
|
||||||
|
msg="Managing idp is not supported by your IPA version")
|
||||||
|
|
||||||
|
commands = []
|
||||||
|
for name in names:
|
||||||
|
# Make sure idp exists
|
||||||
|
res_find = find_idp(ansible_module, name)
|
||||||
|
|
||||||
|
# Create command
|
||||||
|
if state == "present":
|
||||||
|
|
||||||
|
# Generate args
|
||||||
|
args = gen_args(auth_uri, dev_auth_uri, token_uri,
|
||||||
|
userinfo_uri, keys_uri, issuer_url, client_id,
|
||||||
|
secret, scope, idp_user_id, organization,
|
||||||
|
base_url)
|
||||||
|
|
||||||
|
if provider is not None:
|
||||||
|
convert_provider_to_endpoints(ansible_module, args,
|
||||||
|
provider)
|
||||||
|
|
||||||
|
# Found the idp
|
||||||
|
if res_find is not None:
|
||||||
|
# The parameters ipaidpprovider, ipaidporg and
|
||||||
|
# ipaidpbaseurl are only available for idp-add to create
|
||||||
|
# then endpoints using provider, Therefore we have to
|
||||||
|
# remove them from args.
|
||||||
|
for arg in ["ipaidpprovider", "ipaidporg",
|
||||||
|
"ipaidpbaseurl"]:
|
||||||
|
if arg in args:
|
||||||
|
del args[arg]
|
||||||
|
|
||||||
|
# For all settings is args, check if there are
|
||||||
|
# different settings in the find result.
|
||||||
|
# If yes: modify
|
||||||
|
if not compare_args_ipa(ansible_module, args,
|
||||||
|
res_find):
|
||||||
|
commands.append([name, "idp_mod", args])
|
||||||
|
else:
|
||||||
|
commands.append([name, "idp_add", args])
|
||||||
|
|
||||||
|
elif state == "absent":
|
||||||
|
if res_find is not None:
|
||||||
|
_args = {}
|
||||||
|
if delete_continue is not None:
|
||||||
|
_args = {"continue": delete_continue}
|
||||||
|
commands.append([name, "idp_del", _args])
|
||||||
|
|
||||||
|
elif state == "renamed":
|
||||||
|
if not rename:
|
||||||
|
ansible_module.fail_json(msg="No rename value given.")
|
||||||
|
|
||||||
|
if res_find is None:
|
||||||
|
ansible_module.fail_json(
|
||||||
|
msg="No idp found to be renamed: '%s'" % (name))
|
||||||
|
|
||||||
|
if name != rename:
|
||||||
|
commands.append(
|
||||||
|
[name, "idp_mod", {"rename": rename}])
|
||||||
|
|
||||||
|
else:
|
||||||
|
ansible_module.fail_json(msg="Unkown state '%s'" % state)
|
||||||
|
|
||||||
|
# Execute commands
|
||||||
|
|
||||||
|
changed = ansible_module.execute_ipa_commands(commands)
|
||||||
|
|
||||||
|
# Done
|
||||||
|
|
||||||
|
ansible_module.exit_json(changed=changed, **exit_args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
141
tests/idp/test_idp.yml
Normal file
141
tests/idp/test_idp.yml
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
---
|
||||||
|
- name: Test idp
|
||||||
|
hosts: "{{ ipa_test_host | default('ipaserver') }}"
|
||||||
|
become: false
|
||||||
|
gather_facts: false
|
||||||
|
module_defaults:
|
||||||
|
ipaidp:
|
||||||
|
ipaadmin_password: SomeADMINpassword
|
||||||
|
ipaapi_context: "{{ ipa_context | default(omit) }}"
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
|
||||||
|
# CHECK IF WE HAVE IDP SUPPORT
|
||||||
|
|
||||||
|
- name: Verify if ipd management is supported
|
||||||
|
ansible.builtin.shell:
|
||||||
|
cmd: |
|
||||||
|
echo SomeADMINpassword | kinit -c {{ krb5ccname }} admin
|
||||||
|
RESULT=$(KRB5CCNAME={{ krb5ccname }} ipa command-show idp_add)
|
||||||
|
kdestroy -A -c {{ krb5ccname }}
|
||||||
|
echo $RESULT
|
||||||
|
vars:
|
||||||
|
krb5ccname: "__check_command_idp_add__"
|
||||||
|
register: check_command_idp_add
|
||||||
|
|
||||||
|
- name: Run tests for idp
|
||||||
|
when: not "idp_add" in check_command_idp_add.stderr
|
||||||
|
block:
|
||||||
|
|
||||||
|
# CLEANUP TEST ITEMS
|
||||||
|
|
||||||
|
- name: Ensure idps my-keycloak-idp, my-github-idp and my-google-idp are absent
|
||||||
|
ipaidp:
|
||||||
|
name:
|
||||||
|
- my-keycloak-idp
|
||||||
|
- my-github-idp
|
||||||
|
- my-google-idp
|
||||||
|
delete_continue: true
|
||||||
|
state: absent
|
||||||
|
|
||||||
|
# CREATE TEST ITEMS
|
||||||
|
|
||||||
|
# TESTS
|
||||||
|
|
||||||
|
- name: Ensure keycloak idp my-keycloak-idp is present
|
||||||
|
ipaidp:
|
||||||
|
name: my-keycloak-idp
|
||||||
|
provider: keycloak
|
||||||
|
organization: main
|
||||||
|
base_url: keycloak.idm.example.com:8443/auth
|
||||||
|
client_id: my-client-id
|
||||||
|
register: result
|
||||||
|
failed_when: not result.changed or result.failed
|
||||||
|
|
||||||
|
- name: Ensure keycloak idp my-keycloak-idp is present, again
|
||||||
|
ipaidp:
|
||||||
|
name: my-keycloak-idp
|
||||||
|
provider: keycloak
|
||||||
|
organization: main
|
||||||
|
base_url: keycloak.idm.example.com:8443/auth
|
||||||
|
client_id: my-client-id
|
||||||
|
register: result
|
||||||
|
failed_when: result.changed or result.failed
|
||||||
|
|
||||||
|
- name: Ensure idp my-keycloak-idp is absent
|
||||||
|
ipaidp:
|
||||||
|
name: my-keycloak-idp
|
||||||
|
delete_continue: true
|
||||||
|
state: absent
|
||||||
|
|
||||||
|
- name: Ensure keycloak idp my-keycloak-idp is failing with missing parameters
|
||||||
|
ipaidp:
|
||||||
|
name: my-keycloak-idp
|
||||||
|
provider: keycloak
|
||||||
|
client_id: my-client-id
|
||||||
|
register: result
|
||||||
|
failed_when: result.changed or not result.failed or
|
||||||
|
" is missing" not in result.msg
|
||||||
|
|
||||||
|
- name: Ensure github idp my-github-idp is present
|
||||||
|
ipaidp:
|
||||||
|
name: my-github-idp
|
||||||
|
provider: github
|
||||||
|
client_id: my-github-client-id
|
||||||
|
register: result
|
||||||
|
failed_when: not result.changed or result.failed
|
||||||
|
|
||||||
|
- name: Ensure github idp my-github-idp is present, again
|
||||||
|
ipaidp:
|
||||||
|
name: my-github-idp
|
||||||
|
provider: github
|
||||||
|
client_id: my-github-client-id
|
||||||
|
register: result
|
||||||
|
failed_when: result.changed or result.failed
|
||||||
|
|
||||||
|
- name: Ensure google idp my-google-idp is present using provider defaults without specifying provider
|
||||||
|
ipaidp:
|
||||||
|
name: my-google-idp
|
||||||
|
auth_uri: https://accounts.google.com/o/oauth2/auth
|
||||||
|
dev_auth_uri: https://oauth2.googleapis.com/device/code
|
||||||
|
token_uri: https://oauth2.googleapis.com/token
|
||||||
|
keys_uri: https://www.googleapis.com/oauth2/v3/certs
|
||||||
|
userinfo_uri: https://openidconnect.googleapis.com/v1/userinfo
|
||||||
|
client_id: my-google-client-id
|
||||||
|
scope: "openid email"
|
||||||
|
idp_user_id: email
|
||||||
|
register: result
|
||||||
|
failed_when: not result.changed or result.failed
|
||||||
|
|
||||||
|
- name: Ensure google idp my-google-idp is present using provider defaults without specifying provider, again
|
||||||
|
ipaidp:
|
||||||
|
name: my-google-idp
|
||||||
|
auth_uri: https://accounts.google.com/o/oauth2/auth
|
||||||
|
dev_auth_uri: https://oauth2.googleapis.com/device/code
|
||||||
|
token_uri: https://oauth2.googleapis.com/token
|
||||||
|
keys_uri: https://www.googleapis.com/oauth2/v3/certs
|
||||||
|
userinfo_uri: https://openidconnect.googleapis.com/v1/userinfo
|
||||||
|
client_id: my-google-client-id
|
||||||
|
scope: "openid email"
|
||||||
|
idp_user_id: email
|
||||||
|
register: result
|
||||||
|
failed_when: result.changed or result.failed
|
||||||
|
|
||||||
|
- name: Ensure google idp my-google-idp is present without changes using provider
|
||||||
|
ipaidp:
|
||||||
|
name: my-google-idp
|
||||||
|
provider: google
|
||||||
|
client_id: my-google-client-id
|
||||||
|
register: result
|
||||||
|
failed_when: result.changed or result.failed
|
||||||
|
|
||||||
|
# CLEANUP TEST ITEMS
|
||||||
|
|
||||||
|
- name: Ensure idps my-keycloak-idp, my-github-idp and my-google-idp are absent
|
||||||
|
ipaidp:
|
||||||
|
name:
|
||||||
|
- my-keycloak-idp
|
||||||
|
- my-github-idp
|
||||||
|
- my-google-idp
|
||||||
|
delete_continue: true
|
||||||
|
state: absent
|
||||||
40
tests/idp/test_idp_client_context.yml
Normal file
40
tests/idp/test_idp_client_context.yml
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
- name: Test idp
|
||||||
|
hosts: ipaclients, ipaserver
|
||||||
|
# It is normally not needed to set "become" to "true" for a module test.
|
||||||
|
# Only set it to true if it is needed to execute commands as root.
|
||||||
|
become: false
|
||||||
|
# Enable "gather_facts" only if "ansible_facts" variable needs to be used.
|
||||||
|
gather_facts: false
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
- name: Include FreeIPA facts.
|
||||||
|
ansible.builtin.include_tasks: ../env_freeipa_facts.yml
|
||||||
|
|
||||||
|
# Test will only be executed if host is not a server.
|
||||||
|
- name: Execute with server context in the client.
|
||||||
|
ipaidp:
|
||||||
|
ipaadmin_password: SomeADMINpassword
|
||||||
|
ipaapi_context: server
|
||||||
|
name: ThisShouldNotWork
|
||||||
|
register: result
|
||||||
|
failed_when: not (result.failed and result.msg is regex("No module named '*ipaserver'*"))
|
||||||
|
when: ipa_host_is_client
|
||||||
|
|
||||||
|
# Import basic module tests, and execute with ipa_context set to 'client'.
|
||||||
|
# If ipaclients is set, it will be executed using the client, if not,
|
||||||
|
# ipaserver will be used.
|
||||||
|
#
|
||||||
|
# With this setup, tests can be executed against an IPA client, against
|
||||||
|
# an IPA server using "client" context, and ensure that tests are executed
|
||||||
|
# in upstream CI.
|
||||||
|
|
||||||
|
- name: Test idp using client context, in client host.
|
||||||
|
import_playbook: test_idp.yml
|
||||||
|
when: groups['ipaclients']
|
||||||
|
vars:
|
||||||
|
ipa_test_host: ipaclients
|
||||||
|
|
||||||
|
- name: Test idp using client context, in server host.
|
||||||
|
import_playbook: test_idp.yml
|
||||||
|
when: groups['ipaclients'] is not defined or not groups['ipaclients']
|
||||||
@@ -49,6 +49,7 @@ Features
|
|||||||
- Modules for host management
|
- Modules for host management
|
||||||
- Modules for hostgroup management
|
- Modules for hostgroup management
|
||||||
- Modules for idoverrideuser management
|
- Modules for idoverrideuser management
|
||||||
|
- Modules for idp management
|
||||||
- Modules for idrange management
|
- Modules for idrange management
|
||||||
- Modules for idview management
|
- Modules for idview management
|
||||||
- Modules for location management
|
- Modules for location management
|
||||||
|
|||||||
Reference in New Issue
Block a user