> ## Content Index
> Fetch the complete content index at: https://www.pinglabz.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Ansible for Cisco ISE: Network Devices, Device Groups and Endpoints with cisco.ise
- URL: https://www.pinglabz.com/ansible-cisco-ise-network-devices-endpoints/
- Published: 2026-10-31T20:10:44.000Z
- Updated: 2026-10-31T23:01:15.000Z
- Description: Network device groups, five network devices with a vaulted RADIUS secret, and an endpoint identity group with static MACs on ISE 3.5, all with cisco.ise 3.3.0. Includes the SDK schema mismatch that breaks network_device and a drift test.
- Author: Jaime
- Tags: Network Automation, cisco-ise, Cisco

This is part 9 of the [Ansible for network engineers series](https://www.pinglabz.com/ansible-network-automation/). Parts 1 to 8 pushed config to routers, switches and an ASA over SSH. Cisco ISE is different. There's no CLI to template, so the cisco.ise collection talks to the ISE REST APIs instead. This post uses it for the three things every ISE rollout keeps re-typing in the GUI: network device groups, network devices with their RADIUS secret, and an endpoint identity group with a few static MAC addresses.

Built in my home lab with ansible-core 2.21.5, cisco.ise 3.3.0, ciscoisesdk 2.4.7 and Python 3.14, against Cisco ISE 3.5\. If you've seen my [ISE 11007 troubleshooting post](https://www.pinglabz.com/ise-11007-could-not-locate-network-device/), this is how you stop that error from happening in the first place: the device exists in ISE because the playbook put it there.

[![Lab diagram: An Ansible control node (ansible-core 2.21.5, cisco.ise 3.3.0, ciscoisesdk 2.4.7) reads vars/ise-data.yml and the vault-encrypted ise-inv/group_vars/ise/vault.yml and ise-inv/group_vars/ise/secrets.yml, then calls Cisco ISE 3.5 at ise1.example.com over HTTPS, ERS API (local connection), with network_device pinned to the 3.3_patch_1 schema. ISE gets Network Device Groups (Location: HQ, BR1, BR2; Device Type: Switches; Routers already existed), Network Devices hq-core1, hq-edge1, br1-edge1, br1-acc1 and br2-edge1 with the RADIUS secret from vault, and Endpoint Identity Group PL-Printers with 00:50:56:AA:10:01 and 00:50:56:AA:10:02. On the right, the five devices send RADIUS to ISE, which now recognises them (no 11007): hq-core1 10.66.10.11 HQ Switches, hq-edge1 10.66.10.13 HQ Routers, br1-edge1 10.66.10.21 BR1 Routers, br1-acc1 10.66.10.22 BR1 Switches, br2-edge1 10.66.10.31 BR2 Routers. Callout: no_log, because ERS returns the shared secret in plain text.](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/10/ansible-ise-network-devices-endpoints-topology.png)](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/10/ansible-ise-network-devices-endpoints-topology.png?ref=pinglabz.com)

Ansible pushes device groups, devices and PL-Printers to ISE over ERS

## Setup: SDK, API access and inventory

The collection is a thin wrapper around a Python SDK that isn't pulled in automatically:

```bash
ansible-galaxy collection install cisco.ise
pip install ciscoisesdk

```

ISE needs its APIs on. On my node ERS and OpenAPI were already enabled under Administration > System > Settings > API Settings, so I didn't change anything. If yours aren't, enable both there. Use an account with the ERS Admin role. In production give Ansible its own admin rather than borrowing a person's login.

The modules run on the control node and make HTTPS calls, so ISE gets its own inventory with a local connection:

```yaml
# ise-inv/hosts.yml
all:
  children:
    ise:
      hosts:
        ise1:
          ansible_host: ise1.example.com

```

```yaml
# ise-inv/group_vars/ise/connection.yml
ansible_connection: local
ansible_python_interpreter: "{{ ansible_playbook_python }}"
ise_hostname: "{{ ansible_host }}"
ise_username: "{{ vault_ise_username }}"
ise_password: "{{ vault_ise_password }}"
ise_verify: false
ise_version: "3.5.0"

```

`ansible_python_interpreter` matters. Without it Ansible may pick a system Python that doesn't have ciscoisesdk installed. `ise_verify: false` is for a lab with a self-signed certificate; in production point it at a trusted cert instead.

The vault files hold the admin login and the RADIUS shared secret. In the post they're placeholders:

```yaml
# ise-inv/group_vars/ise/vault.yml (encrypted)
vault_ise_username: "<ers-admin-user>"
vault_ise_password: "<ers-admin-password>"
# ise-inv/group_vars/ise/secrets.yml (encrypted)
vault_radius_secret: "<radius-shared-secret>"

```

One more thing: cisco.ise doesn't ship a `module_defaults` group, so you can't set the five connection options once for every module. I used a YAML anchor per play, which you'll see below.

## The data

Everything ISE should know lives in one vars file. The playbooks only loop over it.

```yaml
# vars/ise-data.yml
ise_groups:
  - {name: "Location#All Locations#HQ", type: Location, description: "Headquarters"}
  - {name: "Location#All Locations#BR1", type: Location, description: "Branch 1"}
  - {name: "Location#All Locations#BR2", type: Location, description: "Branch 2"}
  - {name: "Device Type#All Device Types#Switches", type: Device Type, description: "Access and core switches"}

ise_devices:
  - {name: hq-core1, ip: 10.66.10.11, location: HQ, type: Switches}
  - {name: hq-edge1, ip: 10.66.10.13, location: HQ, type: Routers}
  - {name: br1-edge1, ip: 10.66.10.21, location: BR1, type: Routers}
  - {name: br1-acc1, ip: 10.66.10.22, location: BR1, type: Switches}
  - {name: br2-edge1, ip: 10.66.10.31, location: BR2, type: Routers}

ise_endpoint_group: {name: PL-Printers, description: "Printers allowed by MAC (MAB)"}
ise_endpoints:
  - {mac: "00:50:56:AA:10:01", description: "br1 floor printer"}
  - {mac: "00:50:56:AA:10:02", description: "hq lobby printer"}

```

## Read first

Before writing anything I read what ISE already had. This node had devices and groups from earlier labs, and I didn't want a playbook fighting them.

```yaml
# playbooks/70-ise-read.yml
---
- name: Read what ISE already has
  hosts: ise
  gather_facts: false
  module_defaults:
    cisco.ise.network_device_group_info: &ise
      ise_hostname: "{{ ise_hostname }}"
      ise_username: "{{ ise_username }}"
      ise_password: "{{ ise_password }}"
      ise_verify: "{{ ise_verify }}"
      ise_version: "{{ ise_version }}"
    cisco.ise.network_device_info: *ise
  tasks:
    - name: Network device groups
      cisco.ise.network_device_group_info:
      register: ndg
    - name: Network devices
      cisco.ise.network_device_info:
      register: nd
    - name: Summary
      ansible.builtin.debug:
        msg:
          groups: "{{ ndg.ise_response | map(attribute='name') | list }}"
          devices: "{{ nd.ise_response | map(attribute='name') | list }}"

```

```text
ok: [ise1] => {
    "msg": {
        "devices": [
            "acc-sw1",
            "acc-sw2",
            "acc-sw3",
            "acc-sw6",
            "core-rtr5",
            "edge-fw7"
        ],
        "groups": [
            "Device Type#All Device Types",
            "Device Type#All Device Types#Firewalls",
            "Device Type#All Device Types#Routers",
            "IPSEC#Is IPSEC Device",
            "IPSEC#Is IPSEC Device#No",
            "IPSEC#Is IPSEC Device#Yes",
            "Location#All Locations"
        ]
    }
}

```

`Routers` already existed, `Switches` and the three locations didn't. Group names are the full path with `#` separators, and that's the format every other module expects too.

A word on device types. In the rest of this series hq-core1 is the HQ core router, an IOS XE Layer 3 image, and br1-acc1 is the only real Layer 2 switch. Putting hq-core1 under `Switches` is a grouping choice in this data, not a statement about the box. If your policy sets match on device type, file it under `Routers` instead; that's a one word change in `vars/ise-data.yml`.

## Network device groups

```yaml
# playbooks/71-ise-ndg.yml
---
- name: Network device groups
  hosts: ise
  gather_facts: false
  vars_files: [../vars/ise-data.yml]
  module_defaults:
    cisco.ise.network_device_group: &ise
      ise_hostname: "{{ ise_hostname }}"
      ise_username: "{{ ise_username }}"
      ise_password: "{{ ise_password }}"
      ise_verify: "{{ ise_verify }}"
      ise_version: "{{ ise_version }}"
  tasks:
    - name: Location and device type groups
      cisco.ise.network_device_group:
        state: present
        name: "{{ item.name }}"
        othername: "{{ item.type }}"
        description: "{{ item.description }}"
      loop: "{{ ise_groups }}"
      loop_control:
        label: "{{ item.name }}"

```

`othername` is the group type (the root of the tree). I ran it with `--check` first, and that tells you something about this collection:

```text
TASK [Location and device type groups] *****************************************
skipping: [ise1] => (item=Location#All Locations#HQ) 
skipping: [ise1] => (item=Location#All Locations#BR1) 
skipping: [ise1] => (item=Location#All Locations#BR2) 
skipping: [ise1] => (item=Device Type#All Device Types#Switches) 
skipping: [ise1]

```

The cisco.ise modules don't support check mode. They skip, so a dry run tells you nothing about what would change. Read first with the `_info` modules instead, as above. The real run and the rerun:

```text
changed: [ise1] => (item=Location#All Locations#HQ)
changed: [ise1] => (item=Location#All Locations#BR1)
changed: [ise1] => (item=Location#All Locations#BR2)
changed: [ise1] => (item=Device Type#All Device Types#Switches)
PLAY RECAP *********************************************************************
ise1                       : ok=1    changed=1    unreachable=0    failed=0    skipped=0

```

```text
ok: [ise1] => (item=Location#All Locations#HQ)
ok: [ise1] => (item=Location#All Locations#BR1)
ok: [ise1] => (item=Location#All Locations#BR2)
ok: [ise1] => (item=Device Type#All Device Types#Switches)
PLAY RECAP *********************************************************************
ise1                       : ok=1    changed=0    unreachable=0    failed=0    skipped=0

```

![Network Device Groups expanded, showing BR1, BR2, HQ and Switches created by the playbook with their device counts](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/10/ansible-ise-02-network-device-groups.png)

Network Device Groups expanded, showing BR1, BR2, HQ and Switches created by the playbook with their device counts

## Network devices, and the schema bug

```yaml
# playbooks/72-ise-devices.yml
---
- name: Network devices
  hosts: ise
  gather_facts: false
  vars_files: [../vars/ise-data.yml]
  module_defaults:
    cisco.ise.network_device: &ise
      ise_hostname: "{{ ise_hostname }}"
      ise_username: "{{ ise_username }}"
      ise_password: "{{ ise_password }}"
      ise_verify: "{{ ise_verify }}"
      # the SDK 3.5.0 schema demands authenticationSettings.enabled; ISE 3.5 rejects it
      ise_version: 3.3_patch_1
  tasks:
    - name: Add the lab routers and switches
      cisco.ise.network_device:
        state: present
        name: "{{ item.name }}"
        description: "Managed by Ansible"
        profileName: Cisco
        NetworkDeviceIPList:
          - {ipaddress: "{{ item.ip }}", mask: 32}
        NetworkDeviceGroupList:
          - "Location#All Locations#{{ item.location }}"
          - "Device Type#All Device Types#{{ item.type }}"
          - "IPSEC#Is IPSEC Device#No"
        authenticationSettings:
          networkProtocol: RADIUS
          dtlsRequired: false
          radiusSharedSecret: "{{ vault_radius_secret }}"
          enableKeyWrap: false
          keyEncryptionKey: ""
          keyInputFormat: ASCII
      loop: "{{ ise_devices }}"
      loop_control:
        label: "{{ item.name }}"
      no_log: "{{ hide_secrets | default(true) }}"

```

With `ise_version: "3.5.0"` the SDK validates the payload against its 3.5.0 schema before sending it. That schema requires `authenticationSettings.enabled` and `coaPort`. Add `enabled` to satisfy the SDK and ISE 3.5 rejects the request with a "JSON invalidity" error. Leave it out and the SDK refuses to send at all. On top of that the module turns `coaPort` into a float (`1700.0`), which the older schema rejects because it wants an integer.

What worked was pinning this one module to the `3.3_patch_1` schema and leaving `coaPort` out completely. ISE fills in its default of 1700\. The rest of the playbooks stay on `3.5.0`. I found the combination by creating and deleting a throwaway device through the API until a payload was accepted, which is a good habit before you point a loop at five real ones.

Also note `IPSEC#Is IPSEC Device#No`. ISE adds it to every device by default, so I list it to keep the desired state identical to what ISE returns.

```text
changed: [ise1] => (item=(censored due to no_log))
changed: [ise1] => (item=(censored due to no_log))
changed: [ise1] => (item=(censored due to no_log))
changed: [ise1] => (item=(censored due to no_log))
changed: [ise1] => (item=(censored due to no_log))
PLAY RECAP *********************************************************************
ise1                       : ok=1    changed=1    unreachable=0    failed=0    skipped=0

```

The rerun came back with `changed=0` and every item `ok`.

![Network Devices list in ISE 3.5 with the five Ansible managed devices, each described as Managed by Ansible](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/10/ansible-ise-01-network-devices.png)

Network Devices list in ISE 3.5 with the five Ansible managed devices, each described as Managed by Ansible

![hq-edge1 detail page in ISE: description, IP 10.66.10.13/32, Cisco profile and the HQ location group](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/10/ansible-ise-04-hq-edge1-detail.png)

hq-edge1 detail page in ISE: description, IP 10.66.10.13/32, Cisco profile and the HQ location group

### Why no\_log is not optional here

The ERS API returns the RADIUS shared secret in plain text when you read a device. The module compares it with what you sent, which is how idempotency works, but it also means the secret ends up in the task result. Without `no_log` it lands in your terminal, your CI log and any callback plugin. I made it a variable so I can turn it off while debugging a non secret field (`-e hide_secrets=false`), and it defaults to on.

Because the module compares the secret, it also catches a rotation. I ran the play once with a different test secret: one device reported `changed`, and the next normal run put the vaulted secret back. That's what you want when the vault is the source of truth.

### Drift from the GUI

To check it catches GUI edits, I changed hq-edge1's description to "Edited in the GUI" by hand, then reran the play with `hide_secrets=false` so I could see which item moved:

```text
TASK [Add the lab routers and switches] ****************************************
changed: [ise1] => (item=hq-edge1)
PLAY RECAP *********************************************************************
ise1                       : ok=1    changed=1    unreachable=0    failed=0    skipped=0

```

The description went back to "Managed by Ansible", and the following run was `changed=0` again.

## Endpoint identity group and static endpoints

```yaml
# playbooks/73-ise-endpoints.yml
---
- name: Endpoint group and endpoints
  hosts: ise
  gather_facts: false
  vars_files: [../vars/ise-data.yml]
  module_defaults:
    cisco.ise.endpointgroup: &ise
      ise_hostname: "{{ ise_hostname }}"
      ise_username: "{{ ise_username }}"
      ise_password: "{{ ise_password }}"
      ise_verify: "{{ ise_verify }}"
      ise_version: "{{ ise_version }}"
    cisco.ise.endpointgroup_info: *ise
    cisco.ise.endpoint: *ise
  tasks:
    - name: Endpoint identity group
      cisco.ise.endpointgroup:
        state: present
        name: "{{ ise_endpoint_group.name }}"
        description: "{{ ise_endpoint_group.description }}"
        systemDefined: false
    - name: Look up its id
      cisco.ise.endpointgroup_info:
        name: "{{ ise_endpoint_group.name }}"
      register: epg
    - name: Endpoints, statically assigned to the group
      cisco.ise.endpoint:
        state: present
        mac: "{{ item.mac }}"
        name: "{{ item.mac }}"
        description: "{{ item.description }}"
        groupId: "{{ epg.ise_response.id }}"
        staticGroupAssignment: true
        staticProfileAssignment: false
      loop: "{{ ise_endpoints }}"
      loop_control:
        label: "{{ item.mac }}"

```

Two details. `systemDefined: false` is required even though you'd think it's obvious for a group you're creating. And endpoints reference the group by id, not name, so the play looks the id up between the two tasks.

```text
TASK [Endpoint identity group] *************************************************
changed: [ise1]
TASK [Look up its id] **********************************************************
ok: [ise1]
TASK [Endpoints, statically assigned to the group] *****************************
changed: [ise1] => (item=00:50:56:AA:10:01)
changed: [ise1] => (item=00:50:56:AA:10:02)
PLAY RECAP *********************************************************************
ise1                       : ok=3    changed=2    unreachable=0    failed=0    skipped=0

```

The rerun was `changed=0` with all three tasks `ok`.

![Endpoint identity group PL-Printers with both MAC addresses statically assigned](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/10/ansible-ise-03-endpoint-group.png)

Endpoint identity group PL-Printers with both MAC addresses statically assigned

ISE assigned the `VMWare-Device` profile on its own because the MACs I used are in a VMware OUI. With `staticProfileAssignment: false` that's expected. Set it to true and add a `profileId` if you want to pin the profile too.

## What I'd do in production

Give Ansible its own ERS admin and keep that password and every RADIUS secret in vault. Read with the `_info` modules before every change, because check mode does nothing here. Keep `no_log` on anything that touches `authenticationSettings`. Pin `ise_version` per module when the SDK schema and your ISE patch disagree, and leave a comment saying why so the next person doesn't "fix" it. Rerun after every change and expect `changed=0`.

Next in the series: [VLANs, trunks and SVIs on IOS XE from one data model with the resource modules](https://www.pinglabz.com/ansible-ios-vlans-trunks-svis-resource-modules/).