digitalboard.core/roles/opnform/tasks/main.yml
Simon Bärlocher 3ace667b6c
feat(services): refine split-horizon OIDC routing and harden nextcloud patch
- 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
2026-06-02 13:44:08 +02:00

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 %}