Просмотр исходного кода

add roles to deploy and configure rhbk and integrate with openshift oauth

Grega Bremec 1 месяц назад
Родитель
Сommit
04a924ef22

+ 4 - 0
p0f/operators/roles/deploy-rhbk/defaults/main.yml

@@ -0,0 +1,4 @@
+---
+# Variables that are usually overridden.
+kubeadmin_config: "tmp/kubeconfig-ocp4"
+...

+ 134 - 0
p0f/operators/roles/deploy-rhbk/tasks/absent.yml

@@ -0,0 +1,134 @@
+---
+# remove any realm imports
+- name: Delete any realm import for the configured realm.
+  block:
+    - name: Remove the template realm import.
+      kubernetes.core.k8s:
+        kubeconfig: "{{ kubeadmin_config }}"
+        validate_certs: no
+        api_version: k8s.keycloak.org/v2alpha1
+        kind: keycloakrealmimport
+        namespace: "{{ rhbk.namespace | default('keycloak') }}"
+        name: "{{ rhbk.name | default('sso') }}-{{ rhbk.realm | default('sample-realm') }}-import"
+        state: absent
+
+# remove the route
+- name: Remove the re-encrypt route.
+  kubernetes.core.k8s:
+    kubeconfig: "{{ kubeadmin_config }}"
+    validate_certs: no
+    api_version: route.openshift.io/v1
+    kind: route
+    namespace: "{{ rhbk.namespace | default('keycloak') }}"
+    name: "{{ rhbk.name | default('sso') }}"
+    state: absent
+
+- name: Check whether the Keycloak service exists (to remove the annotation from it).
+  kubernetes.core.k8s_info:
+    kubeconfig: "{{ kubeadmin_config }}"
+    validate_certs: no
+    api_version: v1
+    kind: service
+    namespace: "{{ rhbk.namespace | default('keycloak') }}"
+    name: "{{ rhbk.name | default('sso') }}-service"
+  register: service_is_there
+
+# remove the annotation from the service
+- name: Remove the TLS annotation from the service.
+  kubernetes.core.k8s_json_patch:
+    kubeconfig: "{{ kubeadmin_config }}"
+    validate_certs: no
+    api_version: v1
+    kind: service
+    namespace: "{{ rhbk.namespace | default('keycloak') }}"
+    name: "{{ rhbk.name | default('sso') }}-service"
+    patch:
+      - op: remove
+        path: /metadata/annotations/service.beta.openshift.io~1serving-cert-secret-name
+  when:
+    - service_is_there.resources is defined
+    - service_is_there.resources | length == 1
+    - service_is_there.resources[0].metadata is defined
+    - service_is_there.resources[0].metadata.annotations is defined
+    - service_is_there.resources[0].metadata.annotations.keys() is contains("service.beta.openshift.io/serving-cert-secret-name")
+
+# remove the tls secret
+- name: Remove the TLS secret.
+  kubernetes.core.k8s:
+    kubeconfig: "{{ kubeadmin_config }}"
+    validate_certs: no
+    api_version: v1
+    kind: secret
+    namespace: "{{ rhbk.namespace | default('keycloak') }}"
+    name: "{{ rhbk.name | default('sso') }}-tls"
+    state: absent
+
+# remove the keycloak
+- name: Remove the Keycloak.
+  kubernetes.core.k8s:
+    kubeconfig: "{{ kubeadmin_config }}"
+    validate_certs: no
+    api_version: k8s.keycloak.org/v2alpha1
+    kind: keycloak
+    namespace: "{{ rhbk.namespace | default('keycloak') }}"
+    name: "{{ rhbk.name | default('sso') }}"
+    state: absent
+
+# TODO: Wait for anything here?
+
+# remove bootstrap secret
+- name: Remove the bootstrap credentials secret.
+  kubernetes.core.k8s:
+    kubeconfig: "{{ kubeadmin_config }}"
+    validate_certs: no
+    api_version: v1
+    kind: secret
+    namespace: "{{ rhbk.namespace | default('keycloak') }}"
+    name: "{{ rhbk.name | default('sso') }}-auth"
+    state: absent
+
+# remove db service
+- name: Remove the database service.
+  kubernetes.core.k8s:
+    kubeconfig: "{{ kubeadmin_config }}"
+    validate_certs: no
+    api_version: v1
+    kind: service
+    namespace: "{{ rhbk.namespace | default('keycloak') }}"
+    name: "{{ rhbk.name | default('sso') }}-db"
+    state: absent
+
+# remove db sts
+- name: Remove the database statefulset.
+  kubernetes.core.k8s:
+    kubeconfig: "{{ kubeadmin_config }}"
+    validate_certs: no
+    api_version: apps/v1
+    kind: statefulset
+    namespace: "{{ rhbk.namespace | default('keycloak') }}"
+    name: "{{ rhbk.name | default('sso') }}-db"
+    state: absent
+
+# remove db credential secret
+- name: Remove the database credentials secret.
+  kubernetes.core.k8s:
+    kubeconfig: "{{ kubeadmin_config }}"
+    validate_certs: no
+    api_version: v1
+    kind: secret
+    namespace: "{{ rhbk.namespace | default('keycloak') }}"
+    name: "{{ rhbk.name | default('sso') }}-db-auth"
+    state: absent
+
+# remove pvcs
+- name: Finally, remove the PVC(s).
+  kubernetes.core.k8s:
+    kubeconfig: "{{ kubeadmin_config }}"
+    validate_certs: no
+    api_version: v1
+    kind: persistentvolumeclaim
+    namespace: "{{ rhbk.namespace | default('keycloak') }}"
+    # TODO: if rhbk.db.replicas is implemented this will need to become a loop
+    name: "{{ rhbk.name | default('sso') }}-db-data-{{ rhbk.name | default('sso') }}-db-0"
+    state: absent
+...

+ 60 - 0
p0f/operators/roles/deploy-rhbk/tasks/main.yml

@@ -0,0 +1,60 @@
+---
+# Ensures there is an instance of RHBoK running in a configurable namespace.
+#
+# Configures it with a realm, and some users.
+#
+# Required variables:
+#
+# rhbk:
+#   namespace:        namespace to deploy to (keycloak)
+#   name:             name of the instance (sso)
+#   replicas:         how many instances (1)
+#   fqdn:             fqdn of the route (hostname), detected if omitted
+#   admin:            bootstrap admin credentials
+#     username:         username (rhbk)
+#     password:         password (secret)
+#   db:               database-specific settings
+#     image:            db server image (rhel9/postgresql-15:latest)
+#     name:             database name (rhbk)
+#     username:         database owner (rhbk)
+#     password:         db owner's password (secret)
+#     claim_modes:[]    volume claim template access modes, list (ReadWriteOnce)
+#     storage_class:    storage class name, no default (omitted)
+#     size:             pvc size (1Gi)
+#     replicas:         how many instances (TODO ignored for now)
+#   realm:            name of the realm (sample-realm)
+#   clients:[]        a list of clients to create in the realm
+#     - id:             clientId
+#       name:           client (human readable) name (client.id)
+#       secret:         the client secret, if used
+#       base_url:       the base URL for redirects and other bits
+#       direct_grants:  whether to allow direct grants (yes if you allow CLI login, no otherwise, default true)
+#       map_groups:     whether to map groups into a groups claim (default true)
+#   groups:[]         groups to create in the realm, no default (meaning no groups)
+#   users:            users to create in realm, no default (meaning no users)
+#     - username:       required (as it is key)
+#       password:       optional, defaults to "secret"
+#       email:          optional, set to username@example.com if empty
+#       firstname:      optional
+#       lastname:       optional
+#       groups:[]       groups the user should be a member of
+#   state:            present (default) or absent (removes a RHBK instance if found)
+#
+# Optional variables:
+#
+#   kubeadmin_config          the administrator kubeconfig file (tmp/kubeconfig-ocp4)
+#
+# NOTE: Use rhbk_state to override rhbk.state from command line.
+#
+# NOTE: Must have an operator deployed in that namespace prior (use deploy-operators role for that).
+#
+# More info: https://www.keycloak.org/docs-api/latest/rest-api/index.html
+#
+- name: Pick up whatever value we can for rhbk.state.
+  ansible.builtin.set_fact:
+    rhbk_action: "{{ rhbk_state | default(rhbk.state | default('present')) }}"
+
+- name: Include the correct set of tasks.
+  ansible.builtin.include_tasks:
+    file: tasks/{{ rhbk_action }}.yml
+...

+ 477 - 0
p0f/operators/roles/deploy-rhbk/tasks/present.yml

@@ -0,0 +1,477 @@
+---
+# Deploys RHBK in a namespace with the Keycloak operator CSV.
+- name: Check if there is a namespace.
+  kubernetes.core.k8s_info:
+    kubeconfig: "{{ kubeadmin_config }}"
+    validate_certs: no
+    api_version: v1
+    kind: namespace
+    name: "{{ rhbk.namespace | default('keycloak') }}"
+  register: prereq_ns
+
+- name: Fail if not so.
+  ansible.builtin.assert:
+    that:
+      - prereq_ns.resources is defined
+      - prereq_ns.resources | length == 1
+    success_msg: "OK, namespace found."
+    fail_msg: "FATAL: namespace to deploy ({{ rhbk.namespace | default('keycloak') }}) not found. Ensure there is an operator already present."
+
+- name: Check if there is a CSV in the namespace.
+  kubernetes.core.k8s_info:
+    kubeconfig: "{{ kubeadmin_config }}"
+    validate_certs: no
+    api_version: operators.coreos.com/v1alpha1
+    kind: clusterserviceversion
+    namespace: "{{ rhbk.namespace | default('keycloak') }}"
+    label_selectors:
+      - operators.coreos.com/rhbk-operator.keycloak=
+  register: prereq_csv
+
+- name: Fail if not so.
+  ansible.builtin.assert:
+    that:
+      - prereq_csv.resources is defined
+      - prereq_csv.resources | length > 0
+    success_msg: "OK, operator CSV found."
+    fail_msg: "FATAL: Operator is not deployed in the namespace: {{ rhbk.namespace | default('keycloak') }}. Ensure there is an operator already present."
+
+- name: Tech hack. Prevent anything from blowing up because rhbk is defined somewhere, but not its structured contents.
+  ansible.builtin.set_fact:
+    rhbk: "{{ rhbk | default({}) | combine({ 'db': {} }) }}"
+  when:
+    - rhbk.db is not defined
+
+- name: Ensure there is a secret containing DB credentials in the project.
+  kubernetes.core.k8s:
+    kubeconfig: "{{ kubeadmin_config }}"
+    validate_certs: no
+    api_version: v1
+    kind: secret
+    namespace: "{{ rhbk.namespace | default('keycloak') }}"
+    name: "{{ rhbk.name | default('sso') }}-db-auth"
+    resource_definition:
+      data:
+        username: "{{ rhbk.db.username | default('rhbk') | b64encode }}"
+        password: "{{ rhbk.db.password | default('secret') | b64encode }}"
+
+# TODO: ensure that there is no STS to begin with, or if there is, verify that
+#       only the allowed fields would change, if anything. Otherwise:
+#
+#         Forbidden: updates to statefulset spec for fields other than
+#         'replicas', 'ordinals', 'template', 'updateStrategy',
+#         'persistentVolumeClaimRetentionPolicy' and 'minReadySeconds' are
+#         forbidden
+#
+- name: Ensure there is a database sts in the project
+  kubernetes.core.k8s:
+    kubeconfig: "{{ kubeadmin_config }}"
+    validate_certs: no
+    api_version: apps/v1
+    kind: statefulset
+    namespace: "{{ rhbk.namespace | default('keycloak') }}"
+    name: "{{ rhbk.name | default('sso') }}-db"
+    resource_definition:
+      spec:
+        # TODO: implement rhbk.db.replicas at some point?
+        replicas: 1
+        serviceName: "{{ rhbk.name | default('sso') }}-db"
+        selector:
+          matchLabels:
+            app: "{{ rhbk.name | default('sso') }}-db"
+        template:
+          metadata:
+            labels:
+              app: "{{ rhbk.name | default('sso') }}-db"
+          spec:
+            containers:
+              - name: "{{ rhbk.name | default('sso') }}-db"
+                image: "{{ rhbk.db.image | default('registry.redhat.io/rhel9/postgresql-15:latest') }}"
+                volumeMounts:
+                  - mountPath: /var/lib/pgsql/data
+                    name: "{{ rhbk.name | default('sso') }}-db-data"
+                env:
+                  - name: POSTGRESQL_USER
+                    valueFrom:
+                      secretKeyRef:
+                        name: "{{ rhbk.name | default('sso') }}-db-auth"
+                        key: username
+                  - name: POSTGRESQL_PASSWORD
+                    valueFrom:
+                      secretKeyRef:
+                        name: "{{ rhbk.name | default('sso') }}-db-auth"
+                        key: password
+                  - name: POSTGRESQL_DATABASE
+                    value: "{{ rhbk.db.name | default('rhbk') }}"
+        volumeClaimTemplates:
+          - metadata:
+              name: "{{ rhbk.name | default('sso') }}-db-data"
+            spec:
+              accessModes: "{{ rhbk.db.claim_modes | default(['ReadWriteOnce']) }}"
+              storageClassName: "{{ rhbk.db.storage_class | default(omit) }}"
+              resources:
+                requests:
+                  storage: "{{ rhbk.db.size | default('1Gi') }}"
+
+- name: Ensure there is a service for the database as well
+  kubernetes.core.k8s:
+    kubeconfig: "{{ kubeadmin_config }}"
+    validate_certs: no
+    api_version: v1
+    kind: service
+    namespace: "{{ rhbk.namespace | default('keycloak') }}"
+    name: "{{ rhbk.name | default('sso') }}-db"
+    resource_definition:
+      spec:
+        selector:
+          app: "{{ rhbk.name | default('sso') }}-db"
+        type: LoadBalancer
+        ports:
+          - port: 5432
+            targetPort: 5432
+            protocol: TCP
+
+- name: Ensure there is a secret containing Keycloak bootstrap credentials
+  kubernetes.core.k8s:
+    kubeconfig: "{{ kubeadmin_config }}"
+    validate_certs: no
+    api_version: v1
+    kind: secret
+    namespace: "{{ rhbk.namespace | default('keycloak') }}"
+    name: "{{ rhbk.name | default('sso') }}-auth"
+    resource_definition:
+      data:
+        username: "{{ rhbk.admin.username | default('rhbk') | b64encode }}"
+        password: "{{ rhbk.admin.password | default('secret') | b64encode }}"
+
+- name: If there is no FQDN, check what the default domain is.
+  kubernetes.core.k8s_info:
+    kubeconfig: "{{ kubeadmin_config }}"
+    validate_certs: no
+    api_version: operator.openshift.io/v1
+    kind: ingresscontroller
+    namespace: openshift-ingress-operator
+    name: default
+  register: default_ingress
+  when: rhbk.fqdn is not defined
+
+- name: Set a fact that reflects either the FQDN as set, or a composition of vars and default ingress info.
+  ansible.builtin.set_fact:
+    rhbk_fqdn: "{{ rhbk.fqdn | default((rhbk.name | default('sso')) + '-' + (rhbk.namespace | default('keycloak')) + '.' + default_ingress.resources[0].status.domain) }}"
+
+- name: Announce what hostname would be used.
+  ansible.builtin.debug:
+    msg: Using "https://{{ rhbk_fqdn }}" as the hostname.
+
+# TODO: remember if there were changes, and force delete any non-ready pod?
+- name: Lastly, make sure there is a Keycloak
+  kubernetes.core.k8s:
+    kubeconfig: "{{ kubeadmin_config }}"
+    validate_certs: no
+    api_version: k8s.keycloak.org/v2alpha1
+    kind: keycloak
+    namespace: "{{ rhbk.namespace | default('keycloak') }}"
+    name: "{{ rhbk.name | default('sso') }}"
+    resource_definition:
+      spec:
+        instances: "{{ rhbk.replicas | default(1) }}"
+        db:
+          vendor: postgres
+          host: "{{ rhbk.name | default('sso') }}-db"
+          database: "{{ rhbk.db.name | default('rhbk') }}"
+          usernameSecret:
+            name: "{{ rhbk.name | default('sso') }}-db-auth"
+            key: username
+          passwordSecret:
+            name: "{{ rhbk.name | default('sso') }}-db-auth"
+            key: password
+        hostname:
+          hostname: "https://{{ rhbk_fqdn }}"
+          strict: false
+          backchannelDynamic: true
+        http:
+          httpEnabled: true
+          httpPort: 8080
+          httpsPort: 8443
+          tlsSecret: "{{ rhbk.name | default('sso') }}-tls"
+        bootstrapAdmin:
+          user:
+            secret: "{{ rhbk.name | default('sso') }}-auth"
+        ingress:
+          enabled: false
+
+- name: Wait for the service to show up.
+  kubernetes.core.k8s_info:
+    kubeconfig: "{{ kubeadmin_config }}"
+    validate_certs: no
+    api_version: v1
+    kind: service
+    namespace: "{{ rhbk.namespace | default('keycloak') }}"
+    name: "{{ rhbk.name | default('sso') }}-service"
+  register: rhbk_svc
+  until:
+    - rhbk_svc.resources is defined
+    - (rhbk_svc.resources | length) > 0
+  retries: 24
+  delay: 5
+
+- name: Ensure the service is correctly annotated.
+  kubernetes.core.k8s:
+    kubeconfig: "{{ kubeadmin_config }}"
+    validate_certs: no
+    api_version: v1
+    kind: service
+    namespace: "{{ rhbk.namespace | default('keycloak') }}"
+    name: "{{ rhbk.name | default('sso') }}-service"
+    state: patched
+    resource_definition:
+      metadata:
+        annotations:
+          service.beta.openshift.io/serving-cert-secret-name: "{{ rhbk.name | default('sso') }}-tls"
+
+- name: Make sure there is a re-encrypt route.
+  kubernetes.core.k8s:
+    kubeconfig: "{{ kubeadmin_config }}"
+    validate_certs: no
+    api_version: route.openshift.io/v1
+    kind: route
+    namespace: "{{ rhbk.namespace | default('keycloak') }}"
+    name: "{{ rhbk.name | default('sso') }}"
+    resource_definition:
+      spec:
+        to:
+          kind: Service
+          name: "{{ rhbk.name | default('sso') }}-service"
+        port:
+          targetPort: 8443
+        tls:
+          termination: reencrypt
+          insecureEdgeTerminationPolicy: Redirect
+
+- name: Wait for the Keycloak resource to report ready.
+  kubernetes.core.k8s_info:
+    kubeconfig: "{{ kubeadmin_config }}"
+    validate_certs: no
+    api_version: k8s.keycloak.org/v2alpha1
+    kind: keycloak
+    namespace: "{{ rhbk.namespace | default('keycloak') }}"
+    name: "{{ rhbk.name | default('sso') }}"
+  register: rhbk_ready
+  until:
+    - rhbk_ready.resources is defined
+    - rhbk_ready.resources | length == 1
+    - rhbk_ready.resources[0].status is defined
+    - (rhbk_ready.resources[0].status | community.general.json_query('conditions[?type==`Ready`].status'))[0]
+  retries: 24
+  delay: 5
+
+- name: Wait for the Keycloak pod to become ready.
+  ansible.builtin.uri:
+    return_content: yes
+    validate_certs: no
+    url: "https://{{ rhbk_fqdn }}/realms/master"
+  register: rhbk_is_ready
+  until: rhbk_is_ready.status == 200
+  retries: 24
+  delay: 5
+
+- name: Get a fresh bearer token.
+  ansible.builtin.include_tasks:
+    file: tasks/token.yml
+
+- name: Get a list of existing realms.
+  ansible.builtin.uri:
+    method: GET
+    return_content: true
+    validate_certs: false
+    url: "https://{{ rhbk_fqdn }}/admin/realms"
+    headers:
+      Authorization: Bearer {{ admin_token }}
+      Accept: application/json
+  register: rhbk_realms
+
+- name: Store the list of realm names/ids as a fact
+  ansible.builtin.set_fact:
+    realms: "{{ rhbk_realms.json | items2dict(key_name='realm', value_name='id') }}"
+
+- name: Import the realm if not present yet
+  block:
+
+    - name: Check whether there is already a realm import CR
+      kubernetes.core.k8s_info:
+        kubeconfig: "{{ kubeadmin_config }}"
+        validate_certs: no
+        api_version: k8s.keycloak.org/v2alpha1
+        kind: keycloakrealmimport
+        namespace: "{{ rhbk.namespace | default('keycloak') }}"
+      register: realm_imports
+
+    - name: Remove a previous realm import if it happens to be there.
+      kubernetes.core.k8s:
+        kubeconfig: "{{ kubeadmin_config }}"
+        validate_certs: no
+        api_version: k8s.keycloak.org/v2alpha1
+        kind: keycloakrealmimport
+        namespace: "{{ rhbk.namespace | default('keycloak') }}"
+        name: "{{ rhbk.name | default('sso') }}-{{ rhbk.realm | default('sample-realm') }}-import"
+        state: absent
+      when:
+        - realm_imports.resources is defined
+        - (realm_imports.resources | length) > 0
+        - (realm_imports | community.general.json_query('resources[*].metadata.name')) is contains((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-import')
+
+    # TODO: finish templating this one, introduce more settings as needed
+    - name: Apply a template realm import.
+      kubernetes.core.k8s:
+        kubeconfig: "{{ kubeadmin_config }}"
+        validate_certs: no
+        api_version: k8s.keycloak.org/v2alpha1
+        kind: keycloakrealmimport
+        namespace: "{{ rhbk.namespace | default('keycloak') }}"
+        name: "{{ rhbk.name | default('sso') }}-{{ rhbk.realm | default('sample-realm') }}-import"
+        template: templates/realm-import-template.yaml.j2
+      register: created_import
+
+    - name: Wait for the import to rollout.
+      kubernetes.core.k8s_info:
+        kubeconfig: "{{ kubeadmin_config }}"
+        validate_certs: no
+        api_version: v1
+        kind: pod
+        namespace: "{{ rhbk.namespace | default('keycloak') }}"
+        label_selectors:
+          - app=keycloak-realm-import
+      register: import_running
+      until:
+        - import_running.resources is defined
+        - import_running.resources | length > 0
+        - import_running.resources[0].status.phase == "Succeeded"
+      retries: 12
+      delay: 5
+      when: created_import.changed
+
+    - name: Wait for the Keycloak pod to become ready.
+      ansible.builtin.uri:
+        return_content: yes
+        validate_certs: no
+        url: "https://{{ rhbk_fqdn }}/realms/{{ rhbk.realm | default('sample-realm') }}"
+      register: rhbk_is_back
+      until: rhbk_is_back.status == 200
+      retries: 24
+      delay: 5
+      when: created_import.changed
+
+  when:
+    - realms[rhbk.realm | default('sample-realm')] is not defined
+
+- name: Get a fresh bearer token.
+  ansible.builtin.include_tasks:
+    file: tasks/token.yml
+
+- name: Get a list of existing groups in the realm.
+  ansible.builtin.uri:
+    method: GET
+    return_content: true
+    validate_certs: false
+    url: "https://{{ rhbk_fqdn }}/admin/realms/{{ rhbk.realm | default('sample-realm') }}/groups"
+    headers:
+      Authorization: Bearer {{ admin_token }}
+      Accept: application/json
+  register: rhbk_realm_groups
+
+- name: Show what groups were found at verbosity 2+.
+  ansible.builtin.debug:
+    var: rhbk_realm_groups
+    verbosity: 2
+
+- name: Create the groups if necessary.
+  ansible.builtin.uri:
+    method: POST
+    return_content: true
+    validate_certs: false
+    url: "https://{{ rhbk_fqdn }}/admin/realms/{{ rhbk.realm | default('sample-realm') }}/groups"
+    headers:
+      Authorization: Bearer {{ admin_token }}
+      Accept: application/json
+      Content-Type: application/json
+    body_format: json
+    body: |
+      {
+        "name": "{{ item }}"
+      }
+    status_code:
+      - 200
+      - 201
+  register: created_groups
+  loop: "{{ rhbk.groups }}"
+  when:
+    - (rhbk_realm_groups.json | items2dict(key_name='name', value_name='id')).keys() is not contains(item)
+
+- name: Show what groups were created at verbosity 2+.
+  ansible.builtin.debug:
+    var: created_groups
+    verbosity: 2
+
+- name: Get a fresh bearer token.
+  ansible.builtin.include_tasks:
+    file: tasks/token.yml
+
+- name: Get a list of existing users in the realm.
+  ansible.builtin.uri:
+    method: GET
+    return_content: true
+    validate_certs: false
+    url: "https://{{ rhbk_fqdn }}/admin/realms/{{ rhbk.realm | default('sample-realm') }}/users"
+    headers:
+      Authorization: Bearer {{ admin_token }}
+      Accept: application/json
+  register: rhbk_realm_users
+
+- name: Show what users were found at verbosity 2+.
+  ansible.builtin.debug:
+    var: rhbk_realm_users
+    verbosity: 2
+
+- name: Create the users if necessary.
+  ansible.builtin.uri:
+    method: POST
+    return_content: true
+    validate_certs: false
+    url: "https://{{ rhbk_fqdn }}/admin/realms/{{ rhbk.realm | default('sample-realm') }}/users"
+    headers:
+      Authorization: Bearer {{ admin_token }}
+      Accept: application/json
+      Content-Type: application/json
+    body_format: json
+    body: |
+      {
+        "username": "{{ item.username }}",
+        "email": "{{ item.email | default(item.username + '@example.com') }}",
+        "firstName": "{{ item.firstname | default('') }}",
+        "lastName": "{{ item.lastname | default('') }}",
+        "credentials": [
+          {
+            "type": "password",
+            "temporary": false,
+            "value": "{{ item.password | default('secret') }}"
+          }
+        ],
+        "enabled": true,
+        "emailVerified": true,
+        {% if item.groups is defined and (item.groups | length) > 0 %}
+        "groups": [ "{{ item.groups | join('", "') }}" ]
+        {% endif %}
+      }
+    status_code:
+      - 200
+      - 201
+  register: created_users
+  loop: "{{ rhbk.users }}"
+  when:
+    - (rhbk_realm_users.json | items2dict(key_name='username', value_name='id')).keys() is not contains(item.username)
+
+- name: Show what users were created at verbosity 2+.
+  ansible.builtin.debug:
+    var: created_users
+    verbosity: 2
+...

+ 30 - 0
p0f/operators/roles/deploy-rhbk/tasks/token.yml

@@ -0,0 +1,30 @@
+---
+# Required variables:
+#   rhbk_fqdn             the FQDN of the Keycloak server (XXX will blow up without it)
+#   rhbk.admin.username   admin user (default "rhbk")
+#   rhbk.admin.password   admin password (default "secret")
+#
+# Registers (or refreshes) a fact called admin_token which you can use for auth.
+#
+- name: Get an auth token from Keycloak
+  ansible.builtin.uri:
+    method: POST
+    return_content: yes
+    validate_certs: no
+    url: "https://{{ rhbk_fqdn }}/realms/master/protocol/openid-connect/token"
+    headers:
+      Accept: application/json
+    body: "client_id=admin-cli&username={{ rhbk.admin.username | default('rhbk') }}&password={{ rhbk.admin.password | default('secret') }}&grant_type=password"
+  register: sso_token_rsp
+
+- name: Verify that the token is usable.
+  ansible.builtin.assert:
+    that: sso_token_rsp.json is defined and sso_token_rsp.json.access_token is defined
+    fail_msg: "ERROR: Failed to obtain authentication token from Keycloak."
+    success_msg: "OK: got authentication token."
+
+- name: Store the token as a fact
+  ansible.builtin.set_fact:
+    admin_token: "{{ sso_token_rsp.json.access_token }}"
+
+...

+ 1959 - 0
p0f/operators/roles/deploy-rhbk/templates/realm-import-template.yaml.j2

@@ -0,0 +1,1959 @@
+apiVersion: k8s.keycloak.org/v2alpha1
+kind: KeycloakRealmImport
+metadata:
+  name: {{ rhbk.name | default('sso') }}-{{ rhbk.realm | default('sample-realm') }}-import
+  namespace: {{ rhbk.namespace | default('keycloak') }}
+spec:
+  keycloakCRName: {{ rhbk.name | default('sso') }}
+  realm:
+    id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm'))) | ansible.builtin.to_uuid }}
+    realm: {{ rhbk.realm | default('sample-realm') }}
+    notBefore: 0
+    defaultSignatureAlgorithm: RS256
+    revokeRefreshToken: false
+    refreshTokenMaxReuse: 0
+    accessTokenLifespan: 300
+    accessTokenLifespanForImplicitFlow: 900
+    ssoSessionIdleTimeout: 1800
+    ssoSessionMaxLifespan: 36000
+    ssoSessionIdleTimeoutRememberMe: 0
+    ssoSessionMaxLifespanRememberMe: 0
+    offlineSessionIdleTimeout: 2592000
+    offlineSessionMaxLifespanEnabled: false
+    offlineSessionMaxLifespan: 5184000
+    clientSessionIdleTimeout: 0
+    clientSessionMaxLifespan: 0
+    clientOfflineSessionIdleTimeout: 0
+    clientOfflineSessionMaxLifespan: 0
+    accessCodeLifespan: 60
+    accessCodeLifespanUserAction: 300
+    accessCodeLifespanLogin: 1800
+    actionTokenGeneratedByAdminLifespan: 43200
+    actionTokenGeneratedByUserLifespan: 300
+    oauth2DeviceCodeLifespan: 600
+    oauth2DevicePollingInterval: 5
+    enabled: true
+    sslRequired: external
+    registrationAllowed: false
+    registrationEmailAsUsername: false
+    rememberMe: false
+    verifyEmail: false
+    loginWithEmailAllowed: false
+    duplicateEmailsAllowed: false
+    resetPasswordAllowed: false
+    editUsernameAllowed: false
+    bruteForceProtected: false
+    permanentLockout: false
+    maxTemporaryLockouts: 0
+    bruteForceStrategy: MULTIPLE
+    maxFailureWaitSeconds: 900
+    minimumQuickLoginWaitSeconds: 60
+    waitIncrementSeconds: 60
+    quickLoginCheckMilliSeconds: 1000
+    maxDeltaTimeSeconds: 43200
+    failureFactor: 30
+    roles:
+      realm:
+        - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-rr-offline_access') | ansible.builtin.to_uuid }}
+          name: offline_access
+          description: ${role_offline-access}
+          composite: false
+          clientRole: false
+          containerId: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm'))) | ansible.builtin.to_uuid }}
+          attributes: {}
+        - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-rr-default-roles-' + (rhbk.realm | default('sample-realm'))) | ansible.builtin.to_uuid }}
+          name: default-roles-{{ rhbk.realm | default('sample-realm') }}
+          description: ${role_default-roles}
+          composite: true
+          composites:
+            realm:
+              - offline_access
+              - uma_authorization
+            client:
+              account:
+                - view-profile
+                - manage-account
+          clientRole: false
+          containerId: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm'))) | ansible.builtin.to_uuid }}
+          attributes: {}
+        - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-rr-uma_authorization') | ansible.builtin.to_uuid }}
+          name: uma_authorization
+          description: ${role_uma_authorization}
+          composite: false
+          clientRole: false
+          containerId: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm'))) | ansible.builtin.to_uuid }}
+          attributes: {}
+      client:
+        realm-management:
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cr-create-client') | ansible.builtin.to_uuid }}
+            name: create-client
+            description: ${role_create-client}
+            composite: false
+            clientRole: true
+            containerId: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-client-realm-management') | ansible.builtin.to_uuid }}
+            attributes: {}
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cr-query-clients') | ansible.builtin.to_uuid }}
+            name: query-clients
+            description: ${role_query-clients}
+            composite: false
+            clientRole: true
+            containerId: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-client-realm-management') | ansible.builtin.to_uuid }}
+            attributes: {}
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cr-view-identity-providers') | ansible.builtin.to_uuid }}
+            name: view-identity-providers
+            description: ${role_view-identity-providers}
+            composite: false
+            clientRole: true
+            containerId: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-client-realm-management') | ansible.builtin.to_uuid }}
+            attributes: {}
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cr-impersonation') | ansible.builtin.to_uuid }}
+            name: impersonation
+            description: ${role_impersonation}
+            composite: false
+            clientRole: true
+            containerId: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-client-realm-management') | ansible.builtin.to_uuid }}
+            attributes: {}
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cr-view-authorization') | ansible.builtin.to_uuid }}
+            name: view-authorization
+            description: ${role_view-authorization}
+            composite: false
+            clientRole: true
+            containerId: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-client-realm-management') | ansible.builtin.to_uuid }}
+            attributes: {}
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cr-view-realm') | ansible.builtin.to_uuid }}
+            name: view-realm
+            description: ${role_view-realm}
+            composite: false
+            clientRole: true
+            containerId: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-client-realm-management') | ansible.builtin.to_uuid }}
+            attributes: {}
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cr-manage-clients') | ansible.builtin.to_uuid }}
+            name: manage-clients
+            description: ${role_manage-clients}
+            composite: false
+            clientRole: true
+            containerId: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-client-realm-management') | ansible.builtin.to_uuid }}
+            attributes: {}
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cr-manage-users') | ansible.builtin.to_uuid }}
+            name: manage-users
+            description: ${role_manage-users}
+            composite: false
+            clientRole: true
+            containerId: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-client-realm-management') | ansible.builtin.to_uuid }}
+            attributes: {}
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cr-query-realms') | ansible.builtin.to_uuid }}
+            name: query-realms
+            description: ${role_query-realms}
+            composite: false
+            clientRole: true
+            containerId: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-client-realm-management') | ansible.builtin.to_uuid }}
+            attributes: {}
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cr-view-users') | ansible.builtin.to_uuid }}
+            name: view-users
+            description: ${role_view-users}
+            composite: true
+            composites:
+              client:
+                realm-management:
+                  - query-groups
+                  - query-users
+            clientRole: true
+            containerId: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-client-realm-management') | ansible.builtin.to_uuid }}
+            attributes: {}
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cr-realm-admin') | ansible.builtin.to_uuid }}
+            name: realm-admin
+            description: ${role_realm-admin}
+            composite: true
+            composites:
+              client:
+                realm-management:
+                  - create-client
+                  - query-clients
+                  - view-identity-providers
+                  - impersonation
+                  - view-authorization
+                  - view-realm
+                  - manage-users
+                  - manage-clients
+                  - query-realms
+                  - view-users
+                  - manage-realm
+                  - manage-authorization
+                  - query-groups
+                  - manage-events
+                  - manage-identity-providers
+                  - view-clients
+                  - view-events
+                  - query-users
+            clientRole: true
+            containerId: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-client-realm-management') | ansible.builtin.to_uuid }}
+            attributes: {}
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cr-manage-realm') | ansible.builtin.to_uuid }}
+            name: manage-realm
+            description: ${role_manage-realm}
+            composite: false
+            clientRole: true
+            containerId: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-client-realm-management') | ansible.builtin.to_uuid }}
+            attributes: {}
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cr-manage-authorization') | ansible.builtin.to_uuid }}
+            name: manage-authorization
+            description: ${role_manage-authorization}
+            composite: false
+            clientRole: true
+            containerId: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-client-realm-management') | ansible.builtin.to_uuid }}
+            attributes: {}
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cr-query-groups') | ansible.builtin.to_uuid }}
+            name: query-groups
+            description: ${role_query-groups}
+            composite: false
+            clientRole: true
+            containerId: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-client-realm-management') | ansible.builtin.to_uuid }}
+            attributes: {}
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cr-manage-events') | ansible.builtin.to_uuid }}
+            name: manage-events
+            description: ${role_manage-events}
+            composite: false
+            clientRole: true
+            containerId: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-client-realm-management') | ansible.builtin.to_uuid }}
+            attributes: {}
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cr-manage-identity-providers') | ansible.builtin.to_uuid }}
+            name: manage-identity-providers
+            description: ${role_manage-identity-providers}
+            composite: false
+            clientRole: true
+            containerId: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-client-realm-management') | ansible.builtin.to_uuid }}
+            attributes: {}
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cr-view-clients') | ansible.builtin.to_uuid }}
+            name: view-clients
+            description: ${role_view-clients}
+            composite: true
+            composites:
+              client:
+                realm-management:
+                  - query-clients
+            clientRole: true
+            containerId: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-client-realm-management') | ansible.builtin.to_uuid }}
+            attributes: {}
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cr-query-users') | ansible.builtin.to_uuid }}
+            name: query-users
+            description: ${role_query-users}
+            composite: false
+            clientRole: true
+            containerId: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-client-realm-management') | ansible.builtin.to_uuid }}
+            attributes: {}
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cr-view-events') | ansible.builtin.to_uuid }}
+            name: view-events
+            description: ${role_view-events}
+            composite: false
+            clientRole: true
+            containerId: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-client-realm-management') | ansible.builtin.to_uuid }}
+            attributes: {}
+        security-admin-console: []
+{% for client in rhbk.clients %}
+        {{ client.id }}: []
+{% endfor %}
+        admin-cli: []
+        account-console: []
+        broker:
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-br-read-token') | ansible.builtin.to_uuid }}
+            name: read-token
+            description: ${role_read-token}
+            composite: false
+            clientRole: true
+            containerId: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-client-broker') | ansible.builtin.to_uuid }}
+            attributes: {}
+        account:
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-ar-delete-account') | ansible.builtin.to_uuid }}
+            name: delete-account
+            description: ${role_delete-account}
+            composite: false
+            clientRole: true
+            containerId: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-client-account') | ansible.builtin.to_uuid }}
+            attributes: {}
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-ar-manage-consent') | ansible.builtin.to_uuid }}
+            name: manage-consent
+            description: ${role_manage-consent}
+            composite: true
+            composites:
+              client:
+                account:
+                  - view-consent
+            clientRole: true
+            containerId: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-client-account') | ansible.builtin.to_uuid }}
+            attributes: {}
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-ar-view-profile') | ansible.builtin.to_uuid }}
+            name: view-profile
+            description: ${role_view-profile}
+            composite: false
+            clientRole: true
+            containerId: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-client-account') | ansible.builtin.to_uuid }}
+            attributes: {}
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-ar-manage-account') | ansible.builtin.to_uuid }}
+            name: manage-account
+            description: ${role_manage-account}
+            composite: true
+            composites:
+              client:
+                account:
+                  - manage-account-links
+            clientRole: true
+            containerId: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-client-account') | ansible.builtin.to_uuid }}
+            attributes: {}
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-ar-manage-account-links') | ansible.builtin.to_uuid }}
+            name: manage-account-links
+            description: ${role_manage-account-links}
+            composite: false
+            clientRole: true
+            containerId: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-client-account') | ansible.builtin.to_uuid }}
+            attributes: {}
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-ar-view-applications') | ansible.builtin.to_uuid }}
+            name: view-applications
+            description: ${role_view-applications}
+            composite: false
+            clientRole: true
+            containerId: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-client-account') | ansible.builtin.to_uuid }}
+            attributes: {}
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-ar-view-groups') | ansible.builtin.to_uuid }}
+            name: view-groups
+            description: ${role_view-groups}
+            composite: false
+            clientRole: true
+            containerId: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-client-account') | ansible.builtin.to_uuid }}
+            attributes: {}
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-ar-view-consent') | ansible.builtin.to_uuid }}
+            name: view-consent
+            description: ${role_view-consent}
+            composite: false
+            clientRole: true
+            containerId: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-client-account') | ansible.builtin.to_uuid }}
+            attributes: {}
+    groups: []
+    defaultRole:
+      id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-default-roles-' + (rhbk.realm | default('sample-realm'))) | ansible.builtin.to_uuid }}
+      name: default-roles-{{ (rhbk.realm | default('sample-realm')) }}
+      description: ${role_default-roles}
+      composite: true
+      clientRole: false
+      containerId: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm'))) | ansible.builtin.to_uuid }}
+    requiredCredentials:
+      - password
+    otpPolicyType: totp
+    otpPolicyAlgorithm: HmacSHA1
+    otpPolicyInitialCounter: 0
+    otpPolicyDigits: 6
+    otpPolicyLookAheadWindow: 1
+    otpPolicyPeriod: 30
+    otpPolicyCodeReusable: false
+    otpSupportedApplications:
+      - totpAppFreeOTPName
+      - totpAppGoogleName
+      - totpAppMicrosoftAuthenticatorName
+    localizationTexts: {}
+    webAuthnPolicyRpEntityName: keycloak
+    webAuthnPolicySignatureAlgorithms:
+      - ES256
+      - RS256
+    webAuthnPolicyRpId: ""
+    webAuthnPolicyAttestationConveyancePreference: not specified
+    webAuthnPolicyAuthenticatorAttachment: not specified
+    webAuthnPolicyRequireResidentKey: not specified
+    webAuthnPolicyUserVerificationRequirement: not specified
+    webAuthnPolicyCreateTimeout: 0
+    webAuthnPolicyAvoidSameAuthenticatorRegister: false
+    webAuthnPolicyAcceptableAaguids: []
+    webAuthnPolicyExtraOrigins: []
+    webAuthnPolicyPasswordlessRpEntityName: keycloak
+    webAuthnPolicyPasswordlessSignatureAlgorithms:
+      - ES256
+      - RS256
+    webAuthnPolicyPasswordlessRpId: ""
+    webAuthnPolicyPasswordlessAttestationConveyancePreference: not specified
+    webAuthnPolicyPasswordlessAuthenticatorAttachment: not specified
+    webAuthnPolicyPasswordlessRequireResidentKey: not specified
+    webAuthnPolicyPasswordlessUserVerificationRequirement: not specified
+    webAuthnPolicyPasswordlessCreateTimeout: 0
+    webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister: false
+    webAuthnPolicyPasswordlessAcceptableAaguids: []
+    webAuthnPolicyPasswordlessExtraOrigins: []
+    scopeMappings:
+      - clientScope: offline_access
+        roles:
+          - offline_access
+    clientScopeMappings:
+      account:
+        - client: account-console
+          roles:
+            - manage-account
+            - view-groups
+    clients:
+      - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-client-account') | ansible.builtin.to_uuid }}
+        clientId: account
+        name: ${client_account}
+        rootUrl: ${authBaseUrl}
+        baseUrl: /realms/{{ rhbk.realm | default('sample-realm') }}/account/
+        surrogateAuthRequired: false
+        enabled: true
+        alwaysDisplayInConsole: false
+        clientAuthenticatorType: client-secret
+        redirectUris:
+          - /realms/{{ rhbk.realm | default('sample-realm') }}/account/*
+        webOrigins: []
+        notBefore: 0
+        bearerOnly: false
+        consentRequired: false
+        standardFlowEnabled: true
+        implicitFlowEnabled: false
+        directAccessGrantsEnabled: false
+        serviceAccountsEnabled: false
+        publicClient: true
+        frontchannelLogout: false
+        protocol: openid-connect
+        attributes:
+          realm_client: "false"
+          post.logout.redirect.uris: +
+        authenticationFlowBindingOverrides: {}
+        fullScopeAllowed: false
+        nodeReRegistrationTimeout: 0
+        defaultClientScopes:
+          - web-origins
+          - acr
+          - roles
+          - profile
+          - basic
+          - email
+        optionalClientScopes:
+          - address
+          - phone
+          - organization
+          - offline_access
+          - microprofile-jwt
+      - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-client-account-console') | ansible.builtin.to_uuid }}
+        clientId: account-console
+        name: ${client_account-console}
+        rootUrl: ${authBaseUrl}
+        baseUrl: /realms/{{ rhbk.realm | default('sample-realm') }}/account/
+        surrogateAuthRequired: false
+        enabled: true
+        alwaysDisplayInConsole: false
+        clientAuthenticatorType: client-secret
+        redirectUris:
+          - /realms/{{ rhbk.realm | default('sample-realm') }}/account/*
+        webOrigins: []
+        notBefore: 0
+        bearerOnly: false
+        consentRequired: false
+        standardFlowEnabled: true
+        implicitFlowEnabled: false
+        directAccessGrantsEnabled: false
+        serviceAccountsEnabled: false
+        publicClient: true
+        frontchannelLogout: false
+        protocol: openid-connect
+        attributes:
+          realm_client: "false"
+          post.logout.redirect.uris: +
+          pkce.code.challenge.method: S256
+        authenticationFlowBindingOverrides: {}
+        fullScopeAllowed: false
+        nodeReRegistrationTimeout: 0
+        protocolMappers:
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-client-account-console-pm-audience-resolve') | ansible.builtin.to_uuid }}
+            name: audience resolve
+            protocol: openid-connect
+            protocolMapper: oidc-audience-resolve-mapper
+            consentRequired: false
+            config: {}
+        defaultClientScopes:
+          - web-origins
+          - acr
+          - roles
+          - profile
+          - basic
+          - email
+        optionalClientScopes:
+          - address
+          - phone
+          - organization
+          - offline_access
+          - microprofile-jwt
+      - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-client-admin-cli') | ansible.builtin.to_uuid }}
+        clientId: admin-cli
+        name: ${client_admin-cli}
+        surrogateAuthRequired: false
+        enabled: true
+        alwaysDisplayInConsole: false
+        clientAuthenticatorType: client-secret
+        redirectUris: []
+        webOrigins: []
+        notBefore: 0
+        bearerOnly: false
+        consentRequired: false
+        standardFlowEnabled: false
+        implicitFlowEnabled: false
+        directAccessGrantsEnabled: true
+        serviceAccountsEnabled: false
+        publicClient: true
+        frontchannelLogout: false
+        protocol: openid-connect
+        attributes:
+          realm_client: "false"
+          client.use.lightweight.access.token.enabled: "true"
+        authenticationFlowBindingOverrides: {}
+        fullScopeAllowed: true
+        nodeReRegistrationTimeout: 0
+        defaultClientScopes:
+          - web-origins
+          - acr
+          - roles
+          - profile
+          - basic
+          - email
+        optionalClientScopes:
+          - address
+          - phone
+          - organization
+          - offline_access
+          - microprofile-jwt
+      - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-client-broker') | ansible.builtin.to_uuid }}
+        clientId: broker
+        name: ${client_broker}
+        surrogateAuthRequired: false
+        enabled: true
+        alwaysDisplayInConsole: false
+        clientAuthenticatorType: client-secret
+        redirectUris: []
+        webOrigins: []
+        notBefore: 0
+        bearerOnly: true
+        consentRequired: false
+        standardFlowEnabled: true
+        implicitFlowEnabled: false
+        directAccessGrantsEnabled: false
+        serviceAccountsEnabled: false
+        publicClient: false
+        frontchannelLogout: false
+        protocol: openid-connect
+        attributes:
+          realm_client: "true"
+        authenticationFlowBindingOverrides: {}
+        fullScopeAllowed: false
+        nodeReRegistrationTimeout: 0
+        defaultClientScopes:
+          - web-origins
+          - acr
+          - roles
+          - profile
+          - basic
+          - email
+        optionalClientScopes:
+          - address
+          - phone
+          - organization
+          - offline_access
+          - microprofile-jwt
+{% for client in rhbk.clients %}
+      - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-client-' + client.id) | ansible.builtin.to_uuid }}
+        clientId: {{ client.id }}
+        name: "{{ client.name | default(client.id) }}"
+        description: ""
+        rootUrl: {{ client.base_url }}
+        adminUrl: {{ client.base_url }}
+        baseUrl: ""
+        surrogateAuthRequired: false
+        enabled: true
+        alwaysDisplayInConsole: false
+        clientAuthenticatorType: client-secret
+{% if client.secret is defined %}
+        secret: '{{ client.secret }}'
+{% endif %}
+        redirectUris:
+          - {{ client.base_url }}/*
+        webOrigins:
+          - {{ client.base_url }}
+        notBefore: 0
+        bearerOnly: false
+        consentRequired: false
+        standardFlowEnabled: true
+        implicitFlowEnabled: false
+        directAccessGrantsEnabled: {{ client.direct_grants | default(true) | bool }}
+        serviceAccountsEnabled: false
+        publicClient: false
+        frontchannelLogout: true
+        protocol: openid-connect
+        attributes:
+          client.secret.creation.time: "1755544217"
+          request.object.signature.alg: any
+          request.object.encryption.alg: any
+          client.introspection.response.allow.jwt.claim.enabled: "false"
+          standard.token.exchange.enabled: "false"
+          frontchannel.logout.session.required: "true"
+          oauth2.device.authorization.grant.enabled: "false"
+          use.jwks.url: "false"
+          backchannel.logout.revoke.offline.tokens: "false"
+          use.refresh.tokens: "true"
+          realm_client: "false"
+          oidc.ciba.grant.enabled: "false"
+          client.use.lightweight.access.token.enabled: "false"
+          backchannel.logout.session.required: "true"
+          client_credentials.use_refresh_token: "false"
+          request.object.required: not required
+          access.token.header.type.rfc9068: "false"
+          acr.loa.map: '{}'
+          require.pushed.authorization.requests: "false"
+          tls.client.certificate.bound.access.tokens: "false"
+          display.on.consent.screen: "false"
+          request.object.encryption.enc: any
+          token.response.type.bearer.lower-case: "false"
+        authenticationFlowBindingOverrides: {}
+        fullScopeAllowed: true
+        nodeReRegistrationTimeout: -1
+{% if client.map_groups | default(true) %}
+        protocolMappers:
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-client-openshift-pm-groups') | ansible.builtin.to_uuid }}
+            name: groups
+            protocol: openid-connect
+            protocolMapper: oidc-group-membership-mapper
+            consentRequired: false
+            config:
+              claim.name: groups
+              full.path: "false"
+              id.token.claim: "true"
+              access.token.claim: "true"
+              userinfo.token.claim: "true"
+              introspection.token.claim: "true"
+              lightweight.claim: "false"
+              multivalued: "true"
+{% endif %}
+        defaultClientScopes:
+          - web-origins
+          - acr
+          - roles
+          - profile
+          - basic
+          - email
+        optionalClientScopes:
+          - address
+          - phone
+          - organization
+          - offline_access
+          - microprofile-jwt
+{% endfor %}
+      - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-client-realm-management') | ansible.builtin.to_uuid }}
+        clientId: realm-management
+        name: ${client_realm-management}
+        surrogateAuthRequired: false
+        enabled: true
+        alwaysDisplayInConsole: false
+        clientAuthenticatorType: client-secret
+        redirectUris: []
+        webOrigins: []
+        notBefore: 0
+        bearerOnly: true
+        consentRequired: false
+        standardFlowEnabled: true
+        implicitFlowEnabled: false
+        directAccessGrantsEnabled: false
+        serviceAccountsEnabled: false
+        publicClient: false
+        frontchannelLogout: false
+        protocol: openid-connect
+        attributes:
+          realm_client: "true"
+        authenticationFlowBindingOverrides: {}
+        fullScopeAllowed: false
+        nodeReRegistrationTimeout: 0
+        defaultClientScopes:
+          - web-origins
+          - acr
+          - roles
+          - profile
+          - basic
+          - email
+        optionalClientScopes:
+          - address
+          - phone
+          - organization
+          - offline_access
+          - microprofile-jwt
+      - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-client-security-admin-console') | ansible.builtin.to_uuid }}
+        clientId: security-admin-console
+        name: ${client_security-admin-console}
+        rootUrl: ${authAdminUrl}
+        baseUrl: /admin/{{ rhbk.realm | default('sample-realm') }}/console/
+        surrogateAuthRequired: false
+        enabled: true
+        alwaysDisplayInConsole: false
+        clientAuthenticatorType: client-secret
+        redirectUris:
+          - /admin/{{ rhbk.realm | default('sample-realm') }}/console/*
+        webOrigins:
+          - +
+        notBefore: 0
+        bearerOnly: false
+        consentRequired: false
+        standardFlowEnabled: true
+        implicitFlowEnabled: false
+        directAccessGrantsEnabled: false
+        serviceAccountsEnabled: false
+        publicClient: true
+        frontchannelLogout: false
+        protocol: openid-connect
+        attributes:
+          realm_client: "false"
+          client.use.lightweight.access.token.enabled: "true"
+          post.logout.redirect.uris: +
+          pkce.code.challenge.method: S256
+        authenticationFlowBindingOverrides: {}
+        fullScopeAllowed: true
+        nodeReRegistrationTimeout: 0
+        protocolMappers:
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-client-security-admin-console-pm-locale') | ansible.builtin.to_uuid }}
+            name: locale
+            protocol: openid-connect
+            protocolMapper: oidc-usermodel-attribute-mapper
+            consentRequired: false
+            config:
+              introspection.token.claim: "true"
+              userinfo.token.claim: "true"
+              user.attribute: locale
+              id.token.claim: "true"
+              access.token.claim: "true"
+              claim.name: locale
+              jsonType.label: String
+        defaultClientScopes:
+          - web-origins
+          - acr
+          - roles
+          - profile
+          - basic
+          - email
+        optionalClientScopes:
+          - address
+          - phone
+          - organization
+          - offline_access
+          - microprofile-jwt
+    clientScopes:
+      - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-roles') | ansible.builtin.to_uuid }}
+        name: roles
+        description: OpenID Connect scope for add user roles to the access token
+        protocol: openid-connect
+        attributes:
+          include.in.token.scope: "false"
+          consent.screen.text: ${rolesScopeConsentText}
+          display.on.consent.screen: "true"
+        protocolMappers:
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-roles-pm-client-roles') | ansible.builtin.to_uuid }}
+            name: client roles
+            protocol: openid-connect
+            protocolMapper: oidc-usermodel-client-role-mapper
+            consentRequired: false
+            config:
+              user.attribute: foo
+              introspection.token.claim: "true"
+              access.token.claim: "true"
+              claim.name: resource_access.${client_id}.roles
+              jsonType.label: String
+              multivalued: "true"
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-roles-pm-realm-roles') | ansible.builtin.to_uuid }}
+            name: realm roles
+            protocol: openid-connect
+            protocolMapper: oidc-usermodel-realm-role-mapper
+            consentRequired: false
+            config:
+              user.attribute: foo
+              introspection.token.claim: "true"
+              access.token.claim: "true"
+              claim.name: realm_access.roles
+              jsonType.label: String
+              multivalued: "true"
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-roles-pm-audience-resolve') | ansible.builtin.to_uuid }}
+            name: audience resolve
+            protocol: openid-connect
+            protocolMapper: oidc-audience-resolve-mapper
+            consentRequired: false
+            config:
+              introspection.token.claim: "true"
+              access.token.claim: "true"
+      - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-service-account') | ansible.builtin.to_uuid }}
+        name: service_account
+        description: Specific scope for a client enabled for service accounts
+        protocol: openid-connect
+        attributes:
+          include.in.token.scope: "false"
+          display.on.consent.screen: "false"
+        protocolMappers:
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-service-account-pm-client-host') | ansible.builtin.to_uuid }}
+            name: Client Host
+            protocol: openid-connect
+            protocolMapper: oidc-usersessionmodel-note-mapper
+            consentRequired: false
+            config:
+              user.session.note: clientHost
+              id.token.claim: "true"
+              introspection.token.claim: "true"
+              access.token.claim: "true"
+              claim.name: clientHost
+              jsonType.label: String
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-service-account-pm-client-ip') | ansible.builtin.to_uuid }}
+            name: Client IP Address
+            protocol: openid-connect
+            protocolMapper: oidc-usersessionmodel-note-mapper
+            consentRequired: false
+            config:
+              user.session.note: clientAddress
+              id.token.claim: "true"
+              introspection.token.claim: "true"
+              access.token.claim: "true"
+              claim.name: clientAddress
+              jsonType.label: String
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-service-account-pm-client-id') | ansible.builtin.to_uuid }}
+            name: Client ID
+            protocol: openid-connect
+            protocolMapper: oidc-usersessionmodel-note-mapper
+            consentRequired: false
+            config:
+              user.session.note: client_id
+              id.token.claim: "true"
+              introspection.token.claim: "true"
+              access.token.claim: "true"
+              claim.name: client_id
+              jsonType.label: String
+      - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-organization') | ansible.builtin.to_uuid }}
+        name: organization
+        description: Additional claims about the organization a subject belongs to
+        protocol: openid-connect
+        attributes:
+          include.in.token.scope: "true"
+          consent.screen.text: ${organizationScopeConsentText}
+          display.on.consent.screen: "true"
+        protocolMappers:
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-organization-pm-organization') | ansible.builtin.to_uuid }}
+            name: organization
+            protocol: openid-connect
+            protocolMapper: oidc-organization-membership-mapper
+            consentRequired: false
+            config:
+              id.token.claim: "true"
+              introspection.token.claim: "true"
+              access.token.claim: "true"
+              claim.name: organization
+              jsonType.label: String
+              multivalued: "true"
+      - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-mprof-jwt') | ansible.builtin.to_uuid }}
+        name: microprofile-jwt
+        description: Microprofile - JWT built-in scope
+        protocol: openid-connect
+        attributes:
+          include.in.token.scope: "true"
+          display.on.consent.screen: "false"
+        protocolMappers:
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-mprof-jwt-pm-upn') | ansible.builtin.to_uuid }}
+            name: upn
+            protocol: openid-connect
+            protocolMapper: oidc-usermodel-attribute-mapper
+            consentRequired: false
+            config:
+              introspection.token.claim: "true"
+              userinfo.token.claim: "true"
+              user.attribute: username
+              id.token.claim: "true"
+              access.token.claim: "true"
+              claim.name: upn
+              jsonType.label: String
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-mprof-jwt-pm-groups') | ansible.builtin.to_uuid }}
+            name: groups
+            protocol: openid-connect
+            protocolMapper: oidc-usermodel-realm-role-mapper
+            consentRequired: false
+            config:
+              introspection.token.claim: "true"
+              multivalued: "true"
+              user.attribute: foo
+              id.token.claim: "true"
+              access.token.claim: "true"
+              claim.name: groups
+              jsonType.label: String
+      - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-saml-org') | ansible.builtin.to_uuid }}
+        name: saml_organization
+        description: Organization Membership
+        protocol: saml
+        attributes:
+          display.on.consent.screen: "false"
+        protocolMappers:
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-saml-org-pm-organization') | ansible.builtin.to_uuid }}
+            name: organization
+            protocol: saml
+            protocolMapper: saml-organization-membership-mapper
+            consentRequired: false
+            config: {}
+      - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-oidc') | ansible.builtin.to_uuid }}
+        name: acr
+        description: OpenID Connect scope for add acr (authentication context class reference) to the token
+        protocol: openid-connect
+        attributes:
+          include.in.token.scope: "false"
+          display.on.consent.screen: "false"
+        protocolMappers:
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-oidc-pm-acr-loa-level') | ansible.builtin.to_uuid }}
+            name: acr loa level
+            protocol: openid-connect
+            protocolMapper: oidc-acr-mapper
+            consentRequired: false
+            config:
+              id.token.claim: "true"
+              access.token.claim: "true"
+              introspection.token.claim: "true"
+      - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-email') | ansible.builtin.to_uuid }}
+        name: email
+        description: 'OpenID Connect built-in scope: email'
+        protocol: openid-connect
+        attributes:
+          include.in.token.scope: "true"
+          consent.screen.text: ${emailScopeConsentText}
+          display.on.consent.screen: "true"
+        protocolMappers:
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-email-pm-email-vrfd') | ansible.builtin.to_uuid }}
+            name: email verified
+            protocol: openid-connect
+            protocolMapper: oidc-usermodel-property-mapper
+            consentRequired: false
+            config:
+              introspection.token.claim: "true"
+              userinfo.token.claim: "true"
+              user.attribute: emailVerified
+              id.token.claim: "true"
+              access.token.claim: "true"
+              claim.name: email_verified
+              jsonType.label: boolean
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-email-pm-email') | ansible.builtin.to_uuid }}
+            name: email
+            protocol: openid-connect
+            protocolMapper: oidc-usermodel-attribute-mapper
+            consentRequired: false
+            config:
+              introspection.token.claim: "true"
+              userinfo.token.claim: "true"
+              user.attribute: email
+              id.token.claim: "true"
+              access.token.claim: "true"
+              claim.name: email
+              jsonType.label: String
+      - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-role-list') | ansible.builtin.to_uuid }}
+        name: role_list
+        description: SAML role list
+        protocol: saml
+        attributes:
+          consent.screen.text: ${samlRoleListScopeConsentText}
+          display.on.consent.screen: "true"
+        protocolMappers:
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-role-list-pm-role-list') | ansible.builtin.to_uuid }}
+            name: role list
+            protocol: saml
+            protocolMapper: saml-role-list-mapper
+            consentRequired: false
+            config:
+              single: "false"
+              attribute.nameformat: Basic
+              attribute.name: Role
+      - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-phone') | ansible.builtin.to_uuid }}
+        name: phone
+        description: 'OpenID Connect built-in scope: phone'
+        protocol: openid-connect
+        attributes:
+          include.in.token.scope: "true"
+          consent.screen.text: ${phoneScopeConsentText}
+          display.on.consent.screen: "true"
+        protocolMappers:
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-phone-pm-phnum') | ansible.builtin.to_uuid }}
+            name: phone number
+            protocol: openid-connect
+            protocolMapper: oidc-usermodel-attribute-mapper
+            consentRequired: false
+            config:
+              introspection.token.claim: "true"
+              userinfo.token.claim: "true"
+              user.attribute: phoneNumber
+              id.token.claim: "true"
+              access.token.claim: "true"
+              claim.name: phone_number
+              jsonType.label: String
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-phone-pm-phnum-vrfd') | ansible.builtin.to_uuid }}
+            name: phone number verified
+            protocol: openid-connect
+            protocolMapper: oidc-usermodel-attribute-mapper
+            consentRequired: false
+            config:
+              introspection.token.claim: "true"
+              userinfo.token.claim: "true"
+              user.attribute: phoneNumberVerified
+              id.token.claim: "true"
+              access.token.claim: "true"
+              claim.name: phone_number_verified
+              jsonType.label: boolean
+      - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-address') | ansible.builtin.to_uuid }}
+        name: address
+        description: 'OpenID Connect built-in scope: address'
+        protocol: openid-connect
+        attributes:
+          include.in.token.scope: "true"
+          consent.screen.text: ${addressScopeConsentText}
+          display.on.consent.screen: "true"
+        protocolMappers:
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-address-pm-address') | ansible.builtin.to_uuid }}
+            name: address
+            protocol: openid-connect
+            protocolMapper: oidc-address-mapper
+            consentRequired: false
+            config:
+              user.attribute.formatted: formatted
+              user.attribute.country: country
+              introspection.token.claim: "true"
+              user.attribute.postal_code: postal_code
+              userinfo.token.claim: "true"
+              user.attribute.street: street
+              id.token.claim: "true"
+              user.attribute.region: region
+              access.token.claim: "true"
+              user.attribute.locality: locality
+      - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-basic') | ansible.builtin.to_uuid }}
+        name: basic
+        description: OpenID Connect scope for add all basic claims to the token
+        protocol: openid-connect
+        attributes:
+          include.in.token.scope: "false"
+          display.on.consent.screen: "false"
+        protocolMappers:
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-basic-pm-sub') | ansible.builtin.to_uuid }}
+            name: sub
+            protocol: openid-connect
+            protocolMapper: oidc-sub-mapper
+            consentRequired: false
+            config:
+              introspection.token.claim: "true"
+              access.token.claim: "true"
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-basic-pm-auth-time') | ansible.builtin.to_uuid }}
+            name: auth_time
+            protocol: openid-connect
+            protocolMapper: oidc-usersessionmodel-note-mapper
+            consentRequired: false
+            config:
+              user.session.note: AUTH_TIME
+              id.token.claim: "true"
+              introspection.token.claim: "true"
+              access.token.claim: "true"
+              claim.name: auth_time
+              jsonType.label: long
+      - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-offline') | ansible.builtin.to_uuid }}
+        name: offline_access
+        description: 'OpenID Connect built-in scope: offline_access'
+        protocol: openid-connect
+        attributes:
+          consent.screen.text: ${offlineAccessScopeConsentText}
+          display.on.consent.screen: "true"
+      - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-web-origins') | ansible.builtin.to_uuid }}
+        name: web-origins
+        description: OpenID Connect scope for add allowed web origins to the access token
+        protocol: openid-connect
+        attributes:
+          include.in.token.scope: "false"
+          consent.screen.text: ""
+          display.on.consent.screen: "false"
+        protocolMappers:
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-web-origins-pm-allowed-origins') | ansible.builtin.to_uuid }}
+            name: allowed web origins
+            protocol: openid-connect
+            protocolMapper: oidc-allowed-origins-mapper
+            consentRequired: false
+            config:
+              access.token.claim: "true"
+              introspection.token.claim: "true"
+      - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-profile') | ansible.builtin.to_uuid }}
+        name: profile
+        description: 'OpenID Connect built-in scope: profile'
+        protocol: openid-connect
+        attributes:
+          include.in.token.scope: "true"
+          consent.screen.text: ${profileScopeConsentText}
+          display.on.consent.screen: "true"
+        protocolMappers:
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-profile-pm-website') | ansible.builtin.to_uuid }}
+            name: website
+            protocol: openid-connect
+            protocolMapper: oidc-usermodel-attribute-mapper
+            consentRequired: false
+            config:
+              introspection.token.claim: "true"
+              userinfo.token.claim: "true"
+              user.attribute: website
+              id.token.claim: "true"
+              access.token.claim: "true"
+              claim.name: website
+              jsonType.label: String
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-profile-pm-full-name') | ansible.builtin.to_uuid }}
+            name: full name
+            protocol: openid-connect
+            protocolMapper: oidc-full-name-mapper
+            consentRequired: false
+            config:
+              id.token.claim: "true"
+              introspection.token.claim: "true"
+              access.token.claim: "true"
+              userinfo.token.claim: "true"
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-profile-pm-locale') | ansible.builtin.to_uuid }}
+            name: locale
+            protocol: openid-connect
+            protocolMapper: oidc-usermodel-attribute-mapper
+            consentRequired: false
+            config:
+              introspection.token.claim: "true"
+              userinfo.token.claim: "true"
+              user.attribute: locale
+              id.token.claim: "true"
+              access.token.claim: "true"
+              claim.name: locale
+              jsonType.label: String
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-profile-pm-picture') | ansible.builtin.to_uuid }}
+            name: picture
+            protocol: openid-connect
+            protocolMapper: oidc-usermodel-attribute-mapper
+            consentRequired: false
+            config:
+              introspection.token.claim: "true"
+              userinfo.token.claim: "true"
+              user.attribute: picture
+              id.token.claim: "true"
+              access.token.claim: "true"
+              claim.name: picture
+              jsonType.label: String
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-profile-given-name') | ansible.builtin.to_uuid }}
+            name: given name
+            protocol: openid-connect
+            protocolMapper: oidc-usermodel-attribute-mapper
+            consentRequired: false
+            config:
+              introspection.token.claim: "true"
+              userinfo.token.claim: "true"
+              user.attribute: firstName
+              id.token.claim: "true"
+              access.token.claim: "true"
+              claim.name: given_name
+              jsonType.label: String
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-profile-pm-profile') | ansible.builtin.to_uuid }}
+            name: profile
+            protocol: openid-connect
+            protocolMapper: oidc-usermodel-attribute-mapper
+            consentRequired: false
+            config:
+              introspection.token.claim: "true"
+              userinfo.token.claim: "true"
+              user.attribute: profile
+              id.token.claim: "true"
+              access.token.claim: "true"
+              claim.name: profile
+              jsonType.label: String
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-profile-pm-birthdate') | ansible.builtin.to_uuid }}
+            name: birthdate
+            protocol: openid-connect
+            protocolMapper: oidc-usermodel-attribute-mapper
+            consentRequired: false
+            config:
+              introspection.token.claim: "true"
+              userinfo.token.claim: "true"
+              user.attribute: birthdate
+              id.token.claim: "true"
+              access.token.claim: "true"
+              claim.name: birthdate
+              jsonType.label: String
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-profile-pm-zoneinfo') | ansible.builtin.to_uuid }}
+            name: zoneinfo
+            protocol: openid-connect
+            protocolMapper: oidc-usermodel-attribute-mapper
+            consentRequired: false
+            config:
+              introspection.token.claim: "true"
+              userinfo.token.claim: "true"
+              user.attribute: zoneinfo
+              id.token.claim: "true"
+              access.token.claim: "true"
+              claim.name: zoneinfo
+              jsonType.label: String
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-profile-pm-family-name') | ansible.builtin.to_uuid }}
+            name: family name
+            protocol: openid-connect
+            protocolMapper: oidc-usermodel-attribute-mapper
+            consentRequired: false
+            config:
+              introspection.token.claim: "true"
+              userinfo.token.claim: "true"
+              user.attribute: lastName
+              id.token.claim: "true"
+              access.token.claim: "true"
+              claim.name: family_name
+              jsonType.label: String
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-profile-pm-username') | ansible.builtin.to_uuid }}
+            name: username
+            protocol: openid-connect
+            protocolMapper: oidc-usermodel-attribute-mapper
+            consentRequired: false
+            config:
+              introspection.token.claim: "true"
+              userinfo.token.claim: "true"
+              user.attribute: username
+              id.token.claim: "true"
+              access.token.claim: "true"
+              claim.name: preferred_username
+              jsonType.label: String
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-profile-pm-nickname') | ansible.builtin.to_uuid }}
+            name: nickname
+            protocol: openid-connect
+            protocolMapper: oidc-usermodel-attribute-mapper
+            consentRequired: false
+            config:
+              introspection.token.claim: "true"
+              userinfo.token.claim: "true"
+              user.attribute: nickname
+              id.token.claim: "true"
+              access.token.claim: "true"
+              claim.name: nickname
+              jsonType.label: String
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-profile-pm-middle-name') | ansible.builtin.to_uuid }}
+            name: middle name
+            protocol: openid-connect
+            protocolMapper: oidc-usermodel-attribute-mapper
+            consentRequired: false
+            config:
+              introspection.token.claim: "true"
+              userinfo.token.claim: "true"
+              user.attribute: middleName
+              id.token.claim: "true"
+              access.token.claim: "true"
+              claim.name: middle_name
+              jsonType.label: String
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-profile-pm-gender') | ansible.builtin.to_uuid }}
+            name: gender
+            protocol: openid-connect
+            protocolMapper: oidc-usermodel-attribute-mapper
+            consentRequired: false
+            config:
+              introspection.token.claim: "true"
+              userinfo.token.claim: "true"
+              user.attribute: gender
+              id.token.claim: "true"
+              access.token.claim: "true"
+              claim.name: gender
+              jsonType.label: String
+          - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-cscope-profile-pm-updated-at') | ansible.builtin.to_uuid }}
+            name: updated at
+            protocol: openid-connect
+            protocolMapper: oidc-usermodel-attribute-mapper
+            consentRequired: false
+            config:
+              introspection.token.claim: "true"
+              userinfo.token.claim: "true"
+              user.attribute: updatedAt
+              id.token.claim: "true"
+              access.token.claim: "true"
+              claim.name: updated_at
+              jsonType.label: long
+    defaultDefaultClientScopes:
+      - role_list
+      - saml_organization
+      - profile
+      - email
+      - roles
+      - web-origins
+      - acr
+      - basic
+    defaultOptionalClientScopes:
+      - offline_access
+      - address
+      - phone
+      - microprofile-jwt
+      - organization
+    browserSecurityHeaders:
+      contentSecurityPolicyReportOnly: ""
+      xContentTypeOptions: nosniff
+      referrerPolicy: no-referrer
+      xRobotsTag: none
+      xFrameOptions: SAMEORIGIN
+      contentSecurityPolicy: frame-src 'self'; frame-ancestors 'self'; object-src 'none';
+      strictTransportSecurity: max-age=31536000; includeSubDomains
+    smtpServer: {}
+    eventsEnabled: false
+    eventsListeners:
+      - jboss-logging
+    enabledEventTypes: []
+    adminEventsEnabled: false
+    adminEventsDetailsEnabled: false
+    identityProviders: []
+    identityProviderMappers: []
+    components:
+      org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy:
+        - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-regpol-max-clients') | ansible.builtin.to_uuid }}
+          name: Max Clients Limit
+          providerId: max-clients
+          subType: anonymous
+          subComponents: {}
+          config:
+            max-clients:
+              - "200"
+        - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-regpol-fullscope') | ansible.builtin.to_uuid }}
+          name: Full Scope Disabled
+          providerId: scope
+          subType: anonymous
+          subComponents: {}
+          config: {}
+        - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-regpol-allowed-protomap-anon') | ansible.builtin.to_uuid }}
+          name: Allowed Protocol Mapper Types
+          providerId: allowed-protocol-mappers
+          subType: anonymous
+          subComponents: {}
+          config:
+            allowed-protocol-mapper-types:
+              - oidc-sha256-pairwise-sub-mapper
+              - saml-user-attribute-mapper
+              - saml-user-property-mapper
+              - oidc-address-mapper
+              - oidc-usermodel-property-mapper
+              - oidc-full-name-mapper
+              - saml-role-list-mapper
+              - oidc-usermodel-attribute-mapper
+        - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-regpol-trusted-hosts') | ansible.builtin.to_uuid }}
+          name: Trusted Hosts
+          providerId: trusted-hosts
+          subType: anonymous
+          subComponents: {}
+          config:
+            host-sending-registration-request-must-match:
+              - "true"
+            client-uris-must-match:
+              - "true"
+        - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-regpol-allowed-protomap-auth') | ansible.builtin.to_uuid }}
+          name: Allowed Protocol Mapper Types
+          providerId: allowed-protocol-mappers
+          subType: authenticated
+          subComponents: {}
+          config:
+            allowed-protocol-mapper-types:
+              - saml-user-attribute-mapper
+              - saml-user-property-mapper
+              - oidc-sha256-pairwise-sub-mapper
+              - saml-role-list-mapper
+              - oidc-address-mapper
+              - oidc-usermodel-attribute-mapper
+              - oidc-full-name-mapper
+              - oidc-usermodel-property-mapper
+        - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-regpol-consentreq') | ansible.builtin.to_uuid }}
+          name: Consent Required
+          providerId: consent-required
+          subType: anonymous
+          subComponents: {}
+          config: {}
+        - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-regpol-allowed-cliscope-anon') | ansible.builtin.to_uuid }}
+          name: Allowed Client Scopes
+          providerId: allowed-client-templates
+          subType: anonymous
+          subComponents: {}
+          config:
+            allow-default-scopes:
+              - "true"
+        - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-regpol-allowed-cliscope-auth') | ansible.builtin.to_uuid }}
+          name: Allowed Client Scopes
+          providerId: allowed-client-templates
+          subType: authenticated
+          subComponents: {}
+          config:
+            allow-default-scopes:
+              - "true"
+      org.keycloak.keys.KeyProvider:
+        - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-keyprov-hs512') | ansible.builtin.to_uuid }}
+          name: hmac-generated-hs512
+          providerId: hmac-generated
+          subComponents: {}
+          config:
+            priority:
+              - "100"
+            algorithm:
+              - HS512
+        - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-keyprov-aesgen') | ansible.builtin.to_uuid }}
+          name: aes-generated
+          providerId: aes-generated
+          subComponents: {}
+          config:
+            priority:
+              - "100"
+        - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-keyprov-rsaencgen') | ansible.builtin.to_uuid }}
+          name: rsa-enc-generated
+          providerId: rsa-enc-generated
+          subComponents: {}
+          config:
+            priority:
+              - "100"
+            algorithm:
+              - RSA-OAEP
+        - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-keyprov-rsagen') | ansible.builtin.to_uuid }}
+          name: rsa-generated
+          providerId: rsa-generated
+          subComponents: {}
+          config:
+            priority:
+              - "100"
+    internationalizationEnabled: false
+    supportedLocales: []
+    authenticationFlows:
+      - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-authflow-acct-vrfy-opt') | ansible.builtin.to_uuid }}
+        alias: Account verification options
+        description: Method with which to verity the existing account
+        providerId: basic-flow
+        topLevel: false
+        builtIn: true
+        authenticationExecutions:
+          - authenticator: idp-email-verification
+            authenticatorFlow: false
+            requirement: ALTERNATIVE
+            priority: 10
+            autheticatorFlow: false
+            userSetupAllowed: false
+          - authenticatorFlow: true
+            requirement: ALTERNATIVE
+            priority: 20
+            autheticatorFlow: true
+            flowAlias: Verify Existing Account by Re-authentication
+            userSetupAllowed: false
+      - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-authflow-brws-cond-otp') | ansible.builtin.to_uuid }}
+        alias: Browser - Conditional OTP
+        description: Flow to determine if the OTP is required for the authentication
+        providerId: basic-flow
+        topLevel: false
+        builtIn: true
+        authenticationExecutions:
+          - authenticator: conditional-user-configured
+            authenticatorFlow: false
+            requirement: REQUIRED
+            priority: 10
+            autheticatorFlow: false
+            userSetupAllowed: false
+          - authenticator: auth-otp-form
+            authenticatorFlow: false
+            requirement: REQUIRED
+            priority: 20
+            autheticatorFlow: false
+            userSetupAllowed: false
+      - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-authflow-brws-cond-org') | ansible.builtin.to_uuid }}
+        alias: Browser - Conditional Organization
+        description: Flow to determine if the organization identity-first login is to be used
+        providerId: basic-flow
+        topLevel: false
+        builtIn: true
+        authenticationExecutions:
+          - authenticator: conditional-user-configured
+            authenticatorFlow: false
+            requirement: REQUIRED
+            priority: 10
+            autheticatorFlow: false
+            userSetupAllowed: false
+          - authenticator: organization
+            authenticatorFlow: false
+            requirement: ALTERNATIVE
+            priority: 20
+            autheticatorFlow: false
+            userSetupAllowed: false
+      - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-authflow-dg-cond-otp') | ansible.builtin.to_uuid }}
+        alias: Direct Grant - Conditional OTP
+        description: Flow to determine if the OTP is required for the authentication
+        providerId: basic-flow
+        topLevel: false
+        builtIn: true
+        authenticationExecutions:
+          - authenticator: conditional-user-configured
+            authenticatorFlow: false
+            requirement: REQUIRED
+            priority: 10
+            autheticatorFlow: false
+            userSetupAllowed: false
+          - authenticator: direct-grant-validate-otp
+            authenticatorFlow: false
+            requirement: REQUIRED
+            priority: 20
+            autheticatorFlow: false
+            userSetupAllowed: false
+      - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-authflow-fbrok-login-cond-org') | ansible.builtin.to_uuid }}
+        alias: First Broker Login - Conditional Organization
+        description: Flow to determine if the authenticator that adds organization members is to be used
+        providerId: basic-flow
+        topLevel: false
+        builtIn: true
+        authenticationExecutions:
+          - authenticator: conditional-user-configured
+            authenticatorFlow: false
+            requirement: REQUIRED
+            priority: 10
+            autheticatorFlow: false
+            userSetupAllowed: false
+          - authenticator: idp-add-organization-member
+            authenticatorFlow: false
+            requirement: REQUIRED
+            priority: 20
+            autheticatorFlow: false
+            userSetupAllowed: false
+      - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-authflow-fbrok-login-cond-otp') | ansible.builtin.to_uuid }}
+        alias: First broker login - Conditional OTP
+        description: Flow to determine if the OTP is required for the authentication
+        providerId: basic-flow
+        topLevel: false
+        builtIn: true
+        authenticationExecutions:
+          - authenticator: conditional-user-configured
+            authenticatorFlow: false
+            requirement: REQUIRED
+            priority: 10
+            autheticatorFlow: false
+            userSetupAllowed: false
+          - authenticator: auth-otp-form
+            authenticatorFlow: false
+            requirement: REQUIRED
+            priority: 20
+            autheticatorFlow: false
+            userSetupAllowed: false
+      - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-authflow-handle-existing') | ansible.builtin.to_uuid }}
+        alias: Handle Existing Account
+        description: Handle what to do if there is existing account with same email/username like authenticated identity provider
+        providerId: basic-flow
+        topLevel: false
+        builtIn: true
+        authenticationExecutions:
+          - authenticator: idp-confirm-link
+            authenticatorFlow: false
+            requirement: REQUIRED
+            priority: 10
+            autheticatorFlow: false
+            userSetupAllowed: false
+          - authenticatorFlow: true
+            requirement: REQUIRED
+            priority: 20
+            autheticatorFlow: true
+            flowAlias: Account verification options
+            userSetupAllowed: false
+      - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-authflow-org') | ansible.builtin.to_uuid }}
+        alias: Organization
+        providerId: basic-flow
+        topLevel: false
+        builtIn: true
+        authenticationExecutions:
+          - authenticatorFlow: true
+            requirement: CONDITIONAL
+            priority: 10
+            autheticatorFlow: true
+            flowAlias: Browser - Conditional Organization
+            userSetupAllowed: false
+      - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-authflow-cond-otp') | ansible.builtin.to_uuid }}
+        alias: Reset - Conditional OTP
+        description: Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.
+        providerId: basic-flow
+        topLevel: false
+        builtIn: true
+        authenticationExecutions:
+          - authenticator: conditional-user-configured
+            authenticatorFlow: false
+            requirement: REQUIRED
+            priority: 10
+            autheticatorFlow: false
+            userSetupAllowed: false
+          - authenticator: reset-otp
+            authenticatorFlow: false
+            requirement: REQUIRED
+            priority: 20
+            autheticatorFlow: false
+            userSetupAllowed: false
+      - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-authflow-ucreat-or-link') | ansible.builtin.to_uuid }}
+        alias: User creation or linking
+        description: Flow for the existing/non-existing user alternatives
+        providerId: basic-flow
+        topLevel: false
+        builtIn: true
+        authenticationExecutions:
+          - authenticatorConfig: create unique user config
+            authenticator: idp-create-user-if-unique
+            authenticatorFlow: false
+            requirement: ALTERNATIVE
+            priority: 10
+            autheticatorFlow: false
+            userSetupAllowed: false
+          - authenticatorFlow: true
+            requirement: ALTERNATIVE
+            priority: 20
+            autheticatorFlow: true
+            flowAlias: Handle Existing Account
+            userSetupAllowed: false
+      - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-authflow-vrfy-existing-reauth') | ansible.builtin.to_uuid }}
+        alias: Verify Existing Account by Re-authentication
+        description: Reauthentication of existing account
+        providerId: basic-flow
+        topLevel: false
+        builtIn: true
+        authenticationExecutions:
+          - authenticator: idp-username-password-form
+            authenticatorFlow: false
+            requirement: REQUIRED
+            priority: 10
+            autheticatorFlow: false
+            userSetupAllowed: false
+          - authenticatorFlow: true
+            requirement: CONDITIONAL
+            priority: 20
+            autheticatorFlow: true
+            flowAlias: First broker login - Conditional OTP
+            userSetupAllowed: false
+      - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-authflow-brws-based-auth') | ansible.builtin.to_uuid }}
+        alias: browser
+        description: Browser based authentication
+        providerId: basic-flow
+        topLevel: true
+        builtIn: true
+        authenticationExecutions:
+          - authenticator: auth-cookie
+            authenticatorFlow: false
+            requirement: ALTERNATIVE
+            priority: 10
+            autheticatorFlow: false
+            userSetupAllowed: false
+          - authenticator: auth-spnego
+            authenticatorFlow: false
+            requirement: DISABLED
+            priority: 20
+            autheticatorFlow: false
+            userSetupAllowed: false
+          - authenticator: identity-provider-redirector
+            authenticatorFlow: false
+            requirement: ALTERNATIVE
+            priority: 25
+            autheticatorFlow: false
+            userSetupAllowed: false
+          - authenticatorFlow: true
+            requirement: ALTERNATIVE
+            priority: 26
+            autheticatorFlow: true
+            flowAlias: Organization
+            userSetupAllowed: false
+          - authenticatorFlow: true
+            requirement: ALTERNATIVE
+            priority: 30
+            autheticatorFlow: true
+            flowAlias: forms
+            userSetupAllowed: false
+      - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-authflow-base-client-auth') | ansible.builtin.to_uuid }}
+        alias: clients
+        description: Base authentication for clients
+        providerId: client-flow
+        topLevel: true
+        builtIn: true
+        authenticationExecutions:
+          - authenticator: client-secret
+            authenticatorFlow: false
+            requirement: ALTERNATIVE
+            priority: 10
+            autheticatorFlow: false
+            userSetupAllowed: false
+          - authenticator: client-jwt
+            authenticatorFlow: false
+            requirement: ALTERNATIVE
+            priority: 20
+            autheticatorFlow: false
+            userSetupAllowed: false
+          - authenticator: client-secret-jwt
+            authenticatorFlow: false
+            requirement: ALTERNATIVE
+            priority: 30
+            autheticatorFlow: false
+            userSetupAllowed: false
+          - authenticator: client-x509
+            authenticatorFlow: false
+            requirement: ALTERNATIVE
+            priority: 40
+            autheticatorFlow: false
+            userSetupAllowed: false
+      - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-authflow-dgrant-oidc-owner') | ansible.builtin.to_uuid }}
+        alias: direct grant
+        description: OpenID Connect Resource Owner Grant
+        providerId: basic-flow
+        topLevel: true
+        builtIn: true
+        authenticationExecutions:
+          - authenticator: direct-grant-validate-username
+            authenticatorFlow: false
+            requirement: REQUIRED
+            priority: 10
+            autheticatorFlow: false
+            userSetupAllowed: false
+          - authenticator: direct-grant-validate-password
+            authenticatorFlow: false
+            requirement: REQUIRED
+            priority: 20
+            autheticatorFlow: false
+            userSetupAllowed: false
+          - authenticatorFlow: true
+            requirement: CONDITIONAL
+            priority: 30
+            autheticatorFlow: true
+            flowAlias: Direct Grant - Conditional OTP
+            userSetupAllowed: false
+      - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-authflow-docker-auth') | ansible.builtin.to_uuid }}
+        alias: docker auth
+        description: Used by Docker clients to authenticate against the IDP
+        providerId: basic-flow
+        topLevel: true
+        builtIn: true
+        authenticationExecutions:
+          - authenticator: docker-http-basic-authenticator
+            authenticatorFlow: false
+            requirement: REQUIRED
+            priority: 10
+            autheticatorFlow: false
+            userSetupAllowed: false
+      - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-authflow-fbrok-login') | ansible.builtin.to_uuid }}
+        alias: first broker login
+        description: Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account
+        providerId: basic-flow
+        topLevel: true
+        builtIn: true
+        authenticationExecutions:
+          - authenticatorConfig: review profile config
+            authenticator: idp-review-profile
+            authenticatorFlow: false
+            requirement: REQUIRED
+            priority: 10
+            autheticatorFlow: false
+            userSetupAllowed: false
+          - authenticatorFlow: true
+            requirement: REQUIRED
+            priority: 20
+            autheticatorFlow: true
+            flowAlias: User creation or linking
+            userSetupAllowed: false
+          - authenticatorFlow: true
+            requirement: CONDITIONAL
+            priority: 50
+            autheticatorFlow: true
+            flowAlias: First Broker Login - Conditional Organization
+            userSetupAllowed: false
+      - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-authflow-forms') | ansible.builtin.to_uuid }}
+        alias: forms
+        description: Username, password, otp and other auth forms.
+        providerId: basic-flow
+        topLevel: false
+        builtIn: true
+        authenticationExecutions:
+          - authenticator: auth-username-password-form
+            authenticatorFlow: false
+            requirement: REQUIRED
+            priority: 10
+            autheticatorFlow: false
+            userSetupAllowed: false
+          - authenticatorFlow: true
+            requirement: CONDITIONAL
+            priority: 20
+            autheticatorFlow: true
+            flowAlias: Browser - Conditional OTP
+            userSetupAllowed: false
+      - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-authflow-reg-flow') | ansible.builtin.to_uuid }}
+        alias: registration
+        description: Registration flow
+        providerId: basic-flow
+        topLevel: true
+        builtIn: true
+        authenticationExecutions:
+          - authenticator: registration-page-form
+            authenticatorFlow: true
+            requirement: REQUIRED
+            priority: 10
+            autheticatorFlow: true
+            flowAlias: registration form
+            userSetupAllowed: false
+      - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-authflow-reg-form') | ansible.builtin.to_uuid }}
+        alias: registration form
+        description: Registration form
+        providerId: form-flow
+        topLevel: false
+        builtIn: true
+        authenticationExecutions:
+          - authenticator: registration-user-creation
+            authenticatorFlow: false
+            requirement: REQUIRED
+            priority: 20
+            autheticatorFlow: false
+            userSetupAllowed: false
+          - authenticator: registration-password-action
+            authenticatorFlow: false
+            requirement: REQUIRED
+            priority: 50
+            autheticatorFlow: false
+            userSetupAllowed: false
+          - authenticator: registration-recaptcha-action
+            authenticatorFlow: false
+            requirement: DISABLED
+            priority: 60
+            autheticatorFlow: false
+            userSetupAllowed: false
+          - authenticator: registration-terms-and-conditions
+            authenticatorFlow: false
+            requirement: DISABLED
+            priority: 70
+            autheticatorFlow: false
+            userSetupAllowed: false
+      - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-authflow-reset-creds') | ansible.builtin.to_uuid }}
+        alias: reset credentials
+        description: Reset credentials for a user if they forgot their password or something
+        providerId: basic-flow
+        topLevel: true
+        builtIn: true
+        authenticationExecutions:
+          - authenticator: reset-credentials-choose-user
+            authenticatorFlow: false
+            requirement: REQUIRED
+            priority: 10
+            autheticatorFlow: false
+            userSetupAllowed: false
+          - authenticator: reset-credential-email
+            authenticatorFlow: false
+            requirement: REQUIRED
+            priority: 20
+            autheticatorFlow: false
+            userSetupAllowed: false
+          - authenticator: reset-password
+            authenticatorFlow: false
+            requirement: REQUIRED
+            priority: 30
+            autheticatorFlow: false
+            userSetupAllowed: false
+          - authenticatorFlow: true
+            requirement: CONDITIONAL
+            priority: 40
+            autheticatorFlow: true
+            flowAlias: Reset - Conditional OTP
+            userSetupAllowed: false
+      - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-authflow-saml-ecp') | ansible.builtin.to_uuid }}
+        alias: saml ecp
+        description: SAML ECP Profile Authentication Flow
+        providerId: basic-flow
+        topLevel: true
+        builtIn: true
+        authenticationExecutions:
+          - authenticator: http-basic-authenticator
+            authenticatorFlow: false
+            requirement: REQUIRED
+            priority: 10
+            autheticatorFlow: false
+            userSetupAllowed: false
+    authenticatorConfig:
+      - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-authconfig-unique-user') | ansible.builtin.to_uuid }}
+        alias: create unique user config
+        config:
+          require.password.update.after.registration: "false"
+      - id: {{ ((rhbk.name | default('sso')) + '-' + (rhbk.realm | default('sample-realm')) + '-authconfig-review-profile-') | ansible.builtin.to_uuid }}
+        alias: review profile config
+        config:
+          update.profile.on.first.login: missing
+    requiredActions:
+      - alias: CONFIGURE_TOTP
+        name: Configure OTP
+        providerId: CONFIGURE_TOTP
+        enabled: true
+        defaultAction: false
+        priority: 10
+        config: {}
+      - alias: TERMS_AND_CONDITIONS
+        name: Terms and Conditions
+        providerId: TERMS_AND_CONDITIONS
+        enabled: false
+        defaultAction: false
+        priority: 20
+        config: {}
+      - alias: UPDATE_PASSWORD
+        name: Update Password
+        providerId: UPDATE_PASSWORD
+        enabled: true
+        defaultAction: false
+        priority: 30
+        config: {}
+      - alias: UPDATE_PROFILE
+        name: Update Profile
+        providerId: UPDATE_PROFILE
+        enabled: true
+        defaultAction: false
+        priority: 40
+        config: {}
+      - alias: VERIFY_EMAIL
+        name: Verify Email
+        providerId: VERIFY_EMAIL
+        enabled: true
+        defaultAction: false
+        priority: 50
+        config: {}
+      - alias: delete_account
+        name: Delete Account
+        providerId: delete_account
+        enabled: false
+        defaultAction: false
+        priority: 60
+        config: {}
+      - alias: webauthn-register
+        name: Webauthn Register
+        providerId: webauthn-register
+        enabled: true
+        defaultAction: false
+        priority: 70
+        config: {}
+      - alias: webauthn-register-passwordless
+        name: Webauthn Register Passwordless
+        providerId: webauthn-register-passwordless
+        enabled: true
+        defaultAction: false
+        priority: 80
+        config: {}
+      - alias: VERIFY_PROFILE
+        name: Verify Profile
+        providerId: VERIFY_PROFILE
+        enabled: true
+        defaultAction: false
+        priority: 90
+        config: {}
+      - alias: delete_credential
+        name: Delete Credential
+        providerId: delete_credential
+        enabled: true
+        defaultAction: false
+        priority: 100
+        config: {}
+      - alias: update_user_locale
+        name: Update User Locale
+        providerId: update_user_locale
+        enabled: true
+        defaultAction: false
+        priority: 1000
+        config: {}
+    browserFlow: browser
+    registrationFlow: registration
+    directGrantFlow: direct grant
+    resetCredentialsFlow: reset credentials
+    clientAuthenticationFlow: clients
+    dockerAuthenticationFlow: docker auth
+    firstBrokerLoginFlow: first broker login
+    attributes:
+      cibaBackchannelTokenDeliveryMode: poll
+      cibaExpiresIn: "120"
+      cibaAuthRequestedUserHint: login_hint
+      oauth2DeviceCodeLifespan: "600"
+      oauth2DevicePollingInterval: "5"
+      parRequestUriLifespan: "60"
+      cibaInterval: "5"
+      realmReusableOtpCode: "false"
+    keycloakVersion: 26.2.7.redhat-00001
+    userManagedAccessAllowed: false
+    organizationsEnabled: false
+    verifiableCredentialsEnabled: false
+    adminPermissionsEnabled: false
+    clientProfiles:
+      profiles: []
+    clientPolicies:
+      policies: []

+ 4 - 0
p0f/operators/roles/rhbk-authn/defaults/main.yml

@@ -0,0 +1,4 @@
+---
+# Variables that are usually overridden.
+kubeadmin_config: "tmp/kubeconfig-ocp4"
+...

+ 243 - 0
p0f/operators/roles/rhbk-authn/tasks/main.yml

@@ -0,0 +1,243 @@
+---
+# Ensures there is an OIDC identity provider configured in OpenShift, that uses
+# a client defined in RHBK deployed by the deploy-rhbk role.
+#
+# Required variables (some are reused from deploy-rhbk role):
+#
+# openshift:
+#   rhbk_client_id:   the name of a client above to use for authentication (default "openshift")
+#   create_groups:    whether to create the groups from realm in OpenShift as well (default yes)
+#
+# rhbk:
+#   namespace:        namespace to deploy to (keycloak)
+#   name:             name of the instance (sso)
+#   fqdn:             fqdn of the route (hostname), detected if omitted
+#   realm:            name of the realm (sample-realm)
+#   clients:[]        a list of clients in the realm, must include openshift.rhbk_client_id
+#   groups:[]         groups to create, this time create them in OpenShift
+#
+# Optional variables:
+#
+#   kubeadmin_config          the administrator kubeconfig file (tmp/kubeconfig-ocp4)
+#
+# TODO: prerequisite check:
+#   - either a fqdn or an existing keycloak resource coordinates
+#   - admin credentials
+#
+- name: Check that the ingresscontroller's defaultCertificate is set
+  kubernetes.core.k8s_info:
+    kubeconfig: "{{ kubeadmin_config }}"
+    validate_certs: no
+    api_version: operator.openshift.io/v1
+    kind: ingresscontroller
+    namespace: openshift-ingress-operator
+    name: default
+  register: ingress_ca
+
+- name: Get the router's default CA content if default
+  block:
+    - name: Read the default CA secret
+      kubernetes.core.k8s_info:
+        kubeconfig: "{{ kubeadmin_config }}"
+        validate_certs: no
+        api_version: v1
+        kind: secret
+        namespace: openshift-ingress-operator
+        name: router-ca
+      register: ingress_ca_data
+
+    - name: Store the CA cert as an actual fact
+      ansible.builtin.set_fact:
+        ingress_ca: "{{ ingress_ca_data.resources[0].data['tls.crt'] }}"
+
+  when: ingress_ca.resources[0].spec.defaultCertificate is not defined
+
+- name: Get the router's default CA content if other than default
+  block:
+    - name: Read the custom CA secret
+      kubernetes.core.k8s_info:
+        kubeconfig: "{{ kubeadmin_config }}"
+        validate_certs: no
+        api_version: v1
+        kind: secret
+        namespace: openshift-config
+        name: "{{ ingress_ca.resources[0].spec.defaultCertificate.name }}"
+      register: ingress_ca_custom
+
+    - name: Store the CA cert as an actual fact
+      ansible.builtin.set_fact:
+        ingress_ca: "{{ ingress_ca_data.resources[0].data['tls.crt'] }}"
+
+  when: ingress_ca.resources[0].spec.defaultCertificate is defined
+
+- name: Check on oauth/cluster
+  kubernetes.core.k8s_info:
+    kubeconfig: "{{ kubeadmin_config }}"
+    validate_certs: no
+    api_version: config.openshift.io/v1
+    kind: oauth
+    name: cluster
+  register: cluster_auth
+
+- debug:
+    var: cluster_auth.resources[0].spec | community.general.json_query('identityProviders[?type==`OpenID`].name')
+
+- name: Figure out what to do with oauth/cluster - option 1
+  ansible.builtin.set_fact:
+    oauth_op: add
+    oauth_path: /spec/identityProviders/-
+  when: |
+    (cluster_auth.resources[0].spec.identityProviders is not defined) or
+    (cluster_auth.resources[0].spec.identityProviders | length) == 0 or
+    (cluster_auth.resources[0].spec | community.general.json_query('identityProviders[?type==`OpenID`].name')) is not defined
+
+- name: Figure out what to do with oauth/cluster - option 2
+  ansible.builtin.set_fact:
+    oauth_op: replace
+    oauth_path: /spec/identityProviders/{{ lookup('ansible.utils.index_of', (cluster_auth.resources[0].spec | community.general.json_query('identityProviders[*].type')), 'eq', 'OpenID') }}
+  when:
+    - cluster_auth.resources[0].spec.identityProviders is defined
+    - cluster_auth.resources[0].spec.identityProviders | length > 0
+    - cluster_auth.resources[0].spec | community.general.json_query('identityProviders[?type==`OpenID`].name') is defined
+
+- debug:
+    msg: executing {{ oauth_op }} at {{ oauth_path }}
+
+- name: If there is no FQDN, check what the default domain of the cluster is.
+  kubernetes.core.k8s_info:
+    kubeconfig: "{{ kubeadmin_config }}"
+    validate_certs: no
+    api_version: operator.openshift.io/v1
+    kind: ingresscontroller
+    namespace: openshift-ingress-operator
+    name: default
+  register: default_ingress
+  when: rhbk.fqdn is not defined
+
+- name: Set a fact that reflects either the FQDN as set, or a composition of vars and default ingress info.
+  ansible.builtin.set_fact:
+    rhbk_fqdn: "{{ rhbk.fqdn | default((rhbk.name | default('sso')) + '-' + (rhbk.namespace | default('keycloak')) + '.' + default_ingress.resources[0].status.domain) }}"
+
+- name: Announce what hostname would be used.
+  ansible.builtin.debug:
+    msg: Using "https://{{ rhbk_fqdn }}" as the hostname.
+
+- name: Make certain router CA CM exists in openshift-config
+  kubernetes.core.k8s:
+    kubeconfig: "{{ kubeadmin_config }}"
+    validate_certs: no
+    api_version: v1
+    kind: configmap
+    namespace: openshift-config
+    name: sso-ingress-ca
+    definition:
+      metadata:
+        labels:
+          app: sso
+      data:
+        ca.crt: "{{ ingress_ca | string | b64decode }}"
+
+- name: Make certain client secret exists in openshift-config
+  kubernetes.core.k8s:
+    kubeconfig: "{{ kubeadmin_config }}"
+    validate_certs: no
+    api_version: v1
+    kind: secret
+    namespace: openshift-config
+    name: sso-client-secret
+    definition:
+      metadata:
+        labels:
+          app: sso
+      type: Opaque
+      data:
+        clientSecret: "{{ (rhbk | community.general.json_query('clients[?id==`' + (openshift.rhbk_client_id | default('openshift')) + '`].secret'))[0] | b64encode }}"
+
+- name: Create parent node if identityProviders did not exist
+  kubernetes.core.k8s_json_patch:
+    kubeconfig: "{{ kubeadmin_config }}"
+    validate_certs: no
+    api_version: config.openshift.io/v1
+    kind: oauth
+    name: cluster
+    patch:
+      - op: add
+        path: /spec/identityProviders
+        value: []
+  when: (cluster_auth.resources[0].spec.identityProviders is not defined)
+
+- name: Patch oauth/cluster
+  kubernetes.core.k8s_json_patch:
+    kubeconfig: "{{ kubeadmin_config }}"
+    validate_certs: no
+    api_version: config.openshift.io/v1
+    kind: oauth
+    name: cluster
+    patch:
+      - op: "{{ oauth_op }}"
+        path: "{{ oauth_path }}"
+        value:
+          name: "{{ openshift.sso_provider_name | default('oidc') }}"
+          mappingMethod: claim 
+          type: OpenID
+          openID:
+            clientID: "{{ openshift.rhbk_client_id | default('openshift') }}"
+            clientSecret: 
+              name: sso-client-secret
+            ca:
+              name: sso-ingress-ca
+            claims: 
+              preferredUsername:
+              - preferred_username
+              name:
+              - name
+              email:
+              - email
+              groups:
+              - groups
+            issuer: "https://{{ rhbk_fqdn }}/realms/{{ rhbk.realm | default('sample-realm') }}"
+  register: patched_oauth
+
+- name: Wait for OAuth to rollout if the resource was patched.
+  block:
+    - name: Wait for co/authentication to start progressing.
+      kubernetes.core.k8s_info:
+        kubeconfig: "{{ kubeadmin_config }}"
+        validate_certs: no
+        api_version: config.openshift.io/v1
+        kind: clusteroperator
+        name: authentication
+      register: co_auth
+      until:
+        - co_auth.resources is defined
+        - co_auth.resources | length == 1
+        - ((co_auth.resources[0].status | community.general.json_query('conditions[?type==`Progressing`].status'))[0] | bool)
+      retries: 60
+      delay: 5
+
+    - name: Wait for co/authentication to finish progressing.
+      kubernetes.core.k8s_info:
+        kubeconfig: "{{ kubeadmin_config }}"
+        validate_certs: no
+        api_version: config.openshift.io/v1
+        kind: clusteroperator
+        name: authentication
+      register: co_auth
+      until:
+        - co_auth.resources is defined
+        - co_auth.resources | length == 1
+        - not ((co_auth.resources[0].status | community.general.json_query('conditions[?type==`Progressing`].status'))[0] | bool)
+      retries: 60
+      delay: 5
+
+  when: patched_oauth.changed
+
+- name: Ensure OpenShift groups are there as well.
+  kubernetes.core.k8s:
+    kubeconfig: "{{ kubeadmin_config }}"
+    validate_certs: no
+    api_version: user.openshift.io/v1
+    kind: group
+    name: "{{ item }}"
+  loop: "{{ rhbk.groups }}"
+...