- authentik: address the rewrite service by compose service name instead of a network alias on the public FQDN, which shadowed extra_hosts pins and broke OIDC discovery for c-ares-based (Node) resolvers - homarr: add homarr_extra_hosts to pin the IdP FQDN to a LAN IP so OIDC discovery stays in-network while the issuer matches the browser-facing URL - opnform: add opnform_oidc_sso_redirect_root to 302 the root URL to the SSO path (deep-links untouched, /login?bypass=1 break-glass); restart ingress via container restart so envsubst re-renders nginx.conf - nextcloud: make the UserConfig sed workaround fail loud on upstream drift instead of silently skipping (nextcloud/server#59629) - gitignore: exclude the local .ansible/ collection cache
458 lines
17 KiB
YAML
458 lines
17 KiB
YAML
#SPDX-License-Identifier: MIT-0
|
|
---
|
|
# tasks file for opnform
|
|
|
|
# =====================================================================
|
|
# 0. VALIDATION
|
|
# =====================================================================
|
|
|
|
- name: Validate required secrets
|
|
ansible.builtin.assert:
|
|
that:
|
|
- opnform_app_key | length > 0
|
|
- opnform_app_key is match('^base64:[A-Za-z0-9+/=]+$')
|
|
- opnform_jwt_secret | length > 0
|
|
- opnform_front_api_secret | length > 0
|
|
- opnform_db_password | length > 0
|
|
fail_msg: >-
|
|
OpnForm requires opnform_app_key, opnform_jwt_secret,
|
|
opnform_front_api_secret and opnform_db_password.
|
|
Generate with:
|
|
opnform_app_key='base64:'$(openssl rand -base64 32) (the 'base64:' prefix is required);
|
|
opnform_jwt_secret and opnform_front_api_secret via openssl rand -hex 32.
|
|
Provide via OpenBao, Ansible Vault or extra-vars.
|
|
success_msg: Secrets validation passed
|
|
|
|
- name: Validate OIDC configuration when enabled
|
|
ansible.builtin.assert:
|
|
that:
|
|
- opnform_oidc_client_secret | length > 0
|
|
- opnform_oidc_domain | length > 0
|
|
- opnform_admin_email | length > 0
|
|
- opnform_admin_password | length > 0
|
|
fail_msg: >-
|
|
When opnform_oidc_enabled is true, you must set:
|
|
- opnform_oidc_client_secret
|
|
- opnform_oidc_domain (email domain that triggers OIDC)
|
|
- opnform_admin_email / opnform_admin_password
|
|
(the OIDC API requires an authenticated admin; the role logs in
|
|
with these credentials to POST the connection)
|
|
when: opnform_oidc_enabled | bool
|
|
|
|
# =====================================================================
|
|
# 1. PREPARATION
|
|
# =====================================================================
|
|
|
|
- name: Ensure required packages are installed
|
|
ansible.builtin.package:
|
|
name:
|
|
- python3-docker
|
|
state: present
|
|
|
|
- name: Create docker compose directory
|
|
ansible.builtin.file:
|
|
path: "{{ opnform_docker_compose_dir }}"
|
|
state: directory
|
|
mode: '0755'
|
|
|
|
- name: Create OpnForm data directories
|
|
ansible.builtin.file:
|
|
path: "{{ item }}"
|
|
state: directory
|
|
mode: "0755"
|
|
loop:
|
|
- "{{ opnform_docker_volume_dir }}"
|
|
- "{{ opnform_storage_dir }}"
|
|
- "{{ opnform_db_data_dir }}"
|
|
- "{{ opnform_redis_data_dir }}"
|
|
|
|
# =====================================================================
|
|
# 2. CONFIGURATION FILES
|
|
# =====================================================================
|
|
|
|
- name: Deploy nginx ingress configuration
|
|
ansible.builtin.template:
|
|
src: nginx.conf.j2
|
|
dest: "{{ opnform_docker_compose_dir }}/nginx.conf"
|
|
mode: '0644'
|
|
notify: restart opnform ingress
|
|
|
|
# OIDC_FORCE_LOGIN disables OpnForm's password login — including the
|
|
# password-based admin/OIDC bootstrap this role performs below. The
|
|
# bootstrap must therefore run with force-login OFF. To stay idempotent
|
|
# on re-runs (avoid recreating api containers on every apply), we only
|
|
# turn force-login OFF when the bootstrap is actually needed (first run
|
|
# on a fresh host, no OIDC connection yet). Once the connection exists
|
|
# we render the final force-login value straight away, so the compose
|
|
# file is byte-identical across re-runs.
|
|
- name: Probe whether OpnForm is already bootstrapped
|
|
block:
|
|
- name: Check if opnform-api container exists and is healthy
|
|
ansible.builtin.command:
|
|
cmd: docker inspect --format='{% raw %}{{.State.Health.Status}}{% endraw %}' opnform-api
|
|
register: _opnform_api_health_probe
|
|
changed_when: false
|
|
failed_when: false
|
|
|
|
- name: Attempt admin login (only when api is healthy)
|
|
ansible.builtin.uri:
|
|
url: "https://127.0.0.1/api/login"
|
|
method: POST
|
|
headers:
|
|
Host: "{{ opnform_domain }}"
|
|
body_format: json
|
|
body:
|
|
email: "{{ opnform_admin_email }}"
|
|
password: "{{ opnform_admin_password }}"
|
|
status_code: [200, 401, 422]
|
|
validate_certs: false
|
|
register: _opnform_probe_login
|
|
no_log: true
|
|
when:
|
|
- _opnform_api_health_probe.rc == 0
|
|
- _opnform_api_health_probe.stdout == "healthy"
|
|
- opnform_admin_email | length > 0
|
|
- opnform_admin_password | length > 0
|
|
|
|
- name: Probe for existing OIDC connection
|
|
ansible.builtin.uri:
|
|
url: "https://127.0.0.1/api/open/workspaces"
|
|
method: GET
|
|
headers:
|
|
Host: "{{ opnform_domain }}"
|
|
Authorization: "Bearer {{ _opnform_probe_login.json.token }}"
|
|
status_code: 200
|
|
validate_certs: false
|
|
register: _opnform_probe_workspaces
|
|
no_log: true
|
|
when:
|
|
- opnform_oidc_enabled | bool
|
|
- _opnform_probe_login is defined
|
|
- _opnform_probe_login.status | default(0) == 200
|
|
|
|
- name: Probe OIDC connections on default workspace
|
|
ansible.builtin.uri:
|
|
url: "https://127.0.0.1/api/open/workspaces/{{ _opnform_probe_workspaces.json[0].id }}/oidc-connections"
|
|
method: GET
|
|
headers:
|
|
Host: "{{ opnform_domain }}"
|
|
Authorization: "Bearer {{ _opnform_probe_login.json.token }}"
|
|
status_code: 200
|
|
validate_certs: false
|
|
register: _opnform_probe_oidc
|
|
no_log: true
|
|
when:
|
|
- opnform_oidc_enabled | bool
|
|
- _opnform_probe_workspaces is defined
|
|
- _opnform_probe_workspaces.json | default([]) | length > 0
|
|
|
|
- name: Decide whether force-login can render in its final state
|
|
ansible.builtin.set_fact:
|
|
# True when force-login is desired AND admin+OIDC bootstrap has
|
|
# already completed (admin user exists with the configured password,
|
|
# OIDC connection is present). On a fresh host both checks fail and
|
|
# we fall back to false so the bootstrap below can run.
|
|
_opnform_force_login_effective: >-
|
|
{{
|
|
(opnform_oidc_enabled | bool)
|
|
and (opnform_oidc_force_login | bool)
|
|
and (_opnform_probe_login.status | default(0) == 200)
|
|
and ((_opnform_probe_oidc.json | default([])) | length > 0)
|
|
}}
|
|
|
|
- name: Deploy docker-compose file
|
|
ansible.builtin.template:
|
|
src: docker-compose.yml.j2
|
|
dest: "{{ opnform_docker_compose_dir }}/docker-compose.yml"
|
|
mode: '0644'
|
|
register: _opnform_compose_rendered
|
|
notify: restart opnform
|
|
|
|
# =====================================================================
|
|
# 3. CONTAINER STARTUP
|
|
# =====================================================================
|
|
|
|
- name: Start opnform containers
|
|
community.docker.docker_compose_v2:
|
|
project_src: "{{ opnform_docker_compose_dir }}"
|
|
state: present
|
|
wait: true
|
|
wait_timeout: 180
|
|
|
|
# =====================================================================
|
|
# 4. WAIT FOR API READINESS
|
|
# =====================================================================
|
|
|
|
- name: Wait for API container to be healthy
|
|
ansible.builtin.command:
|
|
cmd: docker inspect --format='{% raw %}{{.State.Health.Status}}{% endraw %}' opnform-api
|
|
register: api_health
|
|
until: api_health.stdout == "healthy"
|
|
retries: 30
|
|
delay: 10
|
|
changed_when: false
|
|
|
|
# =====================================================================
|
|
# 5. ADMIN BOOTSTRAP (optional)
|
|
# =====================================================================
|
|
# Skips the self-hosted setup page by registering the first user via
|
|
# OpnForm's /api/register endpoint. Idempotent: a successful login
|
|
# attempt with the same credentials means the user already exists.
|
|
#
|
|
# Skipped entirely when force-login already rendered in its final state
|
|
# (probe in step 2 confirmed admin + connection exist). Re-running the
|
|
# /api/login probe on a force-login-enabled api would 401 and 422, so
|
|
# avoid the noise — and avoid spurious "changed" status from a register
|
|
# call that won't help anyway.
|
|
|
|
- name: Check if OpnForm admin user already exists
|
|
ansible.builtin.uri:
|
|
url: "https://127.0.0.1/api/login"
|
|
method: POST
|
|
headers:
|
|
Host: "{{ opnform_domain }}"
|
|
body_format: json
|
|
body:
|
|
email: "{{ opnform_admin_email }}"
|
|
password: "{{ opnform_admin_password }}"
|
|
status_code: [200, 401, 422]
|
|
validate_certs: false
|
|
register: opnform_admin_login
|
|
when:
|
|
- opnform_admin_email | length > 0
|
|
- opnform_admin_password | length > 0
|
|
- not (_opnform_force_login_effective | bool)
|
|
|
|
- name: Create OpnForm admin user via /api/register
|
|
ansible.builtin.uri:
|
|
url: "https://127.0.0.1/api/register"
|
|
method: POST
|
|
headers:
|
|
Host: "{{ opnform_domain }}"
|
|
body_format: json
|
|
body:
|
|
name: "{{ opnform_admin_name }}"
|
|
email: "{{ opnform_admin_email }}"
|
|
password: "{{ opnform_admin_password }}"
|
|
password_confirmation: "{{ opnform_admin_password }}"
|
|
hear_about_us: "{{ opnform_admin_hear_about_us }}"
|
|
status_code: [200, 201]
|
|
validate_certs: false
|
|
no_log: true
|
|
when:
|
|
- opnform_admin_email | length > 0
|
|
- opnform_admin_password | length > 0
|
|
- not (_opnform_force_login_effective | bool)
|
|
- opnform_admin_login.status | default(0) != 200
|
|
|
|
# =====================================================================
|
|
# 6. OIDC IDENTITY CONNECTION (optional)
|
|
# =====================================================================
|
|
# Provisions a single OIDC connection on the admin's default workspace.
|
|
# OpnForm enforces one OIDC connection per workspace, so we GET the
|
|
# existing connections first and then either POST a new one or PATCH the
|
|
# existing one to the desired state. PATCHing (rather than skipping when
|
|
# one exists) keeps inventory changes — e.g. a corrected issuer — applied
|
|
# on re-runs instead of leaving stale values in the DB forever.
|
|
#
|
|
# Skipped on re-applies when force-login is already enabled — the API
|
|
# password login required for these calls is disabled, and the connection
|
|
# is known to exist (otherwise force-login wouldn't have rendered in its
|
|
# final state in step 2). To intentionally re-provision the connection
|
|
# from inventory changes on such a host: temporarily set
|
|
# opnform_oidc_force_login=false, re-apply, then set it back to true.
|
|
|
|
- name: Log in as admin to obtain OIDC API token
|
|
ansible.builtin.uri:
|
|
url: "https://127.0.0.1/api/login"
|
|
method: POST
|
|
headers:
|
|
Host: "{{ opnform_domain }}"
|
|
body_format: json
|
|
body:
|
|
email: "{{ opnform_admin_email }}"
|
|
password: "{{ opnform_admin_password }}"
|
|
status_code: 200
|
|
validate_certs: false
|
|
register: opnform_oidc_token
|
|
no_log: true
|
|
when:
|
|
- opnform_oidc_enabled | bool
|
|
- not (_opnform_force_login_effective | bool)
|
|
|
|
- name: Fetch admin's workspaces
|
|
ansible.builtin.uri:
|
|
url: "https://127.0.0.1/api/open/workspaces"
|
|
method: GET
|
|
headers:
|
|
Host: "{{ opnform_domain }}"
|
|
Authorization: "Bearer {{ opnform_oidc_token.json.token }}"
|
|
status_code: 200
|
|
validate_certs: false
|
|
register: opnform_workspaces
|
|
no_log: true
|
|
when:
|
|
- opnform_oidc_enabled | bool
|
|
- not (_opnform_force_login_effective | bool)
|
|
|
|
- name: Fetch existing OIDC connections for the default workspace
|
|
ansible.builtin.uri:
|
|
url: "https://127.0.0.1/api/open/workspaces/{{ opnform_workspaces.json[0].id }}/oidc-connections"
|
|
method: GET
|
|
headers:
|
|
Host: "{{ opnform_domain }}"
|
|
Authorization: "Bearer {{ opnform_oidc_token.json.token }}"
|
|
status_code: 200
|
|
validate_certs: false
|
|
register: opnform_existing_oidc
|
|
no_log: true
|
|
when:
|
|
- opnform_oidc_enabled | bool
|
|
- not (_opnform_force_login_effective | bool)
|
|
|
|
- name: Resolve OIDC group-role mappings
|
|
ansible.builtin.set_fact:
|
|
_opnform_oidc_group_role_mappings: >-
|
|
{{
|
|
opnform_oidc_group_role_mappings
|
|
if (opnform_oidc_group_role_mappings | length > 0)
|
|
else
|
|
([{'idp_group': opnform_oidc_admin_group, 'role': 'admin'}]
|
|
if (opnform_oidc_admin_group | length > 0) else [])
|
|
}}
|
|
when:
|
|
- opnform_oidc_enabled | bool
|
|
- not (_opnform_force_login_effective | bool)
|
|
|
|
# Desired connection state shared by both the create (POST) and update
|
|
# (PATCH) calls below. client_secret is always sent: OpnForm's update
|
|
# endpoint only persists it when present, and on create it is required.
|
|
- name: Build desired OIDC connection body
|
|
ansible.builtin.set_fact:
|
|
_opnform_oidc_body:
|
|
name: "{{ opnform_oidc_client_name }}"
|
|
slug: "{{ opnform_oidc_slug }}"
|
|
domain: "{{ opnform_oidc_domain }}"
|
|
issuer: "{{ opnform_oidc_issuer }}"
|
|
client_id: "{{ opnform_oidc_client_id }}"
|
|
client_secret: "{{ opnform_oidc_client_secret }}"
|
|
scopes: "{{ opnform_oidc_scopes }}"
|
|
enabled: true
|
|
options:
|
|
require_state: true
|
|
group_role_mappings: "{{ _opnform_oidc_group_role_mappings }}"
|
|
no_log: true
|
|
when:
|
|
- opnform_oidc_enabled | bool
|
|
- not (_opnform_force_login_effective | bool)
|
|
|
|
- name: Create OIDC identity connection
|
|
ansible.builtin.uri:
|
|
url: "https://127.0.0.1/api/open/workspaces/{{ opnform_workspaces.json[0].id }}/oidc-connections"
|
|
method: POST
|
|
headers:
|
|
Host: "{{ opnform_domain }}"
|
|
Authorization: "Bearer {{ opnform_oidc_token.json.token }}"
|
|
body_format: json
|
|
body: "{{ _opnform_oidc_body }}"
|
|
status_code: [201]
|
|
validate_certs: false
|
|
no_log: true
|
|
when:
|
|
- opnform_oidc_enabled | bool
|
|
- not (_opnform_force_login_effective | bool)
|
|
- opnform_existing_oidc.json | length == 0
|
|
|
|
# An OIDC connection already exists: PATCH it to the desired state so
|
|
# inventory changes (e.g. a corrected issuer) are applied. OpnForm allows
|
|
# exactly one connection per workspace, so the first entry is ours.
|
|
- name: Update existing OIDC identity connection
|
|
ansible.builtin.uri:
|
|
url: >-
|
|
https://127.0.0.1/api/open/workspaces/{{ opnform_workspaces.json[0].id }}/oidc-connections/{{ opnform_existing_oidc.json[0].id }}
|
|
method: PATCH
|
|
headers:
|
|
Host: "{{ opnform_domain }}"
|
|
Authorization: "Bearer {{ opnform_oidc_token.json.token }}"
|
|
body_format: json
|
|
body: "{{ _opnform_oidc_body }}"
|
|
status_code: [200]
|
|
validate_certs: false
|
|
no_log: true
|
|
when:
|
|
- opnform_oidc_enabled | bool
|
|
- not (_opnform_force_login_effective | bool)
|
|
- opnform_existing_oidc.json | length > 0
|
|
|
|
# =====================================================================
|
|
# 7. ENABLE FORCE LOGIN (first-run only)
|
|
# =====================================================================
|
|
# On the very first apply, step 2 rendered the compose file with
|
|
# force-login disabled (so the bootstrap above could use the password
|
|
# login). Now that the OIDC connection exists, re-render the compose
|
|
# file with force-login in its final state and recreate the api
|
|
# containers once.
|
|
#
|
|
# On all subsequent applies the probe in step 2 already rendered the
|
|
# final value, the compose file is byte-identical here, and this block
|
|
# is a no-op (the template task reports "ok", no recreate).
|
|
- name: Enable force login (first run, after OIDC bootstrap)
|
|
when:
|
|
- opnform_oidc_enabled | bool
|
|
- opnform_oidc_force_login | bool
|
|
- not (_opnform_force_login_effective | bool)
|
|
block:
|
|
- name: Re-render compose with force-login enabled
|
|
ansible.builtin.set_fact:
|
|
_opnform_force_login_effective: true
|
|
|
|
- name: Deploy docker-compose file with force-login enabled
|
|
ansible.builtin.template:
|
|
src: docker-compose.yml.j2
|
|
dest: "{{ opnform_docker_compose_dir }}/docker-compose.yml"
|
|
mode: '0644'
|
|
register: _opnform_force_login_compose
|
|
|
|
- name: Apply force-login by recreating the api containers
|
|
community.docker.docker_compose_v2:
|
|
project_src: "{{ opnform_docker_compose_dir }}"
|
|
state: present
|
|
wait: true
|
|
wait_timeout: 180
|
|
when: _opnform_force_login_compose is changed
|
|
|
|
- name: Restart ingress so nginx picks up the new api container IPs
|
|
community.docker.docker_container:
|
|
name: opnform-ingress
|
|
state: started
|
|
restart: true
|
|
when: _opnform_force_login_compose is changed
|
|
|
|
- name: Display deployment info
|
|
ansible.builtin.debug:
|
|
msg: |-
|
|
OpnForm deployed at {{ opnform_base_url }}
|
|
|
|
{% if opnform_admin_email | length > 0 %}
|
|
Admin user bootstrapped:
|
|
Email: {{ opnform_admin_email }}
|
|
Password: (from opnform_admin_password)
|
|
{% else %}
|
|
No admin bootstrap configured — visit {{ opnform_base_url }} and
|
|
complete the self-hosted setup page to create the first user.
|
|
Set opnform_admin_email + opnform_admin_password to automate this.
|
|
{% endif %}
|
|
|
|
{% if opnform_oidc_enabled %}
|
|
OIDC: connection "{{ opnform_oidc_client_name }}" bootstrapped
|
|
(slug: {{ opnform_oidc_slug }}, domain: {{ opnform_oidc_domain }})
|
|
Users with @{{ opnform_oidc_domain }} addresses will be
|
|
redirected to {{ opnform_oidc_issuer }} on login.
|
|
{% if opnform_oidc_sso_entrypoint %}
|
|
Login intercept active: {{ opnform_base_url }}/login forwards
|
|
directly to the IdP. Use {{ opnform_base_url }}/login?bypass=1
|
|
as a break-glass path for the email form when the IdP is down.
|
|
{% endif %}
|
|
{% else %}
|
|
OIDC: disabled (set opnform_oidc_enabled=true to auto-configure)
|
|
{% endif %}
|