feat(opnform)!: add admin and OIDC bootstrap, rename role to lowercase

Rename roles/OpnForm → roles/opnform so the role resolves as
  digitalboard.core.opnform (Ansible collection convention is
  lowercase). Update tests/test.yml reference accordingly.

  Add automated admin user creation via POST /api/register, gated on
  opnform_admin_email + opnform_admin_password. Idempotent through a
  prior login probe. Without these vars the manual setup page flow is
  preserved.

  Add automated OIDC IdentityConnection setup via the per-workspace
  /api/open/workspaces/{id}/oidc-connections endpoint, gated on
  opnform_oidc_enabled. Hard-coupled to the admin bootstrap (the API
  requires an authenticated admin token); validation block fails fast
  if OIDC is enabled without admin credentials. Supports both an
  explicit opnform_oidc_group_role_mappings list and a fallback
  opnform_oidc_admin_group convenience var.

  Convert opnform_oidc_scopes from space-separated string to YAML list
  to match OpnForm's API expectation. Rewrite README "First login" and
  "OIDC setup" sections to reflect that self-hosted OpnForm does not
  ship a pre-seeded admin and to document the new bootstrap paths.
  BREAKING CHANGE: opnform_oidc_scopes changed from space-separated
  string to YAML list. Inventories that override it must update from
  "openid profile email" to [openid, profile, email].
This commit is contained in:
Tobias Wüst 2026-05-18 22:40:19 +02:00 committed by Simon Bärlocher
parent eb51b6a054
commit 256a82df1f
No known key found for this signature in database
GPG key ID: 63DE20495932047A
11 changed files with 366 additions and 145 deletions

View file

@ -1,117 +0,0 @@
#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 (prefix 'base64:'), opnform_jwt_secret,
opnform_front_api_secret and opnform_db_password.
Generate with: openssl rand -base64 32
The app_key MUST be prefixed with "base64:"
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
fail_msg: >-
opnform_oidc_client_secret must be set when opnform_oidc_enabled is true.
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
- name: Deploy docker-compose file
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ opnform_docker_compose_dir }}/docker-compose.yml"
mode: '0644'
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
- name: Display deployment info
ansible.builtin.debug:
msg: |-
OpnForm deployed at {{ opnform_base_url }}
Default credentials (from API container logs on first start):
Email: admin@opnform.com
Password: password
On first login you will be prompted to change email and password.
If login does not respond, the DB seed may have failed. Run:
docker compose -f {{ opnform_docker_compose_dir }}/docker-compose.yml exec api php artisan migrate:refresh --seed
docker compose -f {{ opnform_docker_compose_dir }}/docker-compose.yml exec api php artisan app:init-project
OIDC: {% if opnform_oidc_enabled %}enabled (configure via Admin UI){% else %}disabled{% endif %}

View file

@ -13,7 +13,6 @@ Docker Compose stack behind Traefik.
## What this role does NOT do (stage 1)
- Does not pre-create an admin user (use the default credentials below)
- Does not pre-configure OIDC / identity_connections — set up via Admin UI
## Architecture note: why two reverse proxies?
@ -61,39 +60,83 @@ missing or malformed.
## First login
After the role completes, OpnForm seeds a default admin user. Visit
the URL in `opnform_base_url` and log in with:
OpnForm in self-hosted mode does **not** ship a pre-seeded admin user.
The first user to register becomes the owner of the default workspace,
and further public registration is disabled afterwards (additional
users must be invited via the Admin UI).
- Email: `admin@opnform.com`
- Password: `password`
This role supports two ways to create that first user:
On first login OpnForm will prompt you to change email and password.
Self-hosted instances disable public registration after this — invite
further users via the Admin UI.
### Option A — automated bootstrap (recommended)
### If the login does not respond
Set `opnform_admin_email` and `opnform_admin_password` (ideally from
Vault / OpenBao). The role then POSTs to `/api/register` after the
API container is healthy, skipping the setup page entirely. The task
is idempotent: it does a login check first and only registers if the
user does not already exist.
The DB seed may have failed. Re-run it manually:
```bash
cd /etc/docker/compose/opnform
docker compose exec api php artisan migrate:refresh --seed
docker compose exec api php artisan app:init-project
```yaml
opnform_admin_name: "Administrator" # default
opnform_admin_email: "admin@example.com"
opnform_admin_password: "{{ vault_opnform_admin_password }}"
```
## OIDC setup (stage 2, not yet automated)
Password rules enforced by OpnForm: minimum 8 characters, at least one
letter, one digit, and one of `@$!%*#?&-_+=.,:;<>^()[]{}|~`.
Manual setup via the Admin UI is currently the supported path:
### Option B — manual setup page
1. Settings → Identity Connections → Add Connection
2. Provider: OIDC
3. Issuer: `https://auth.digitalboard.ch/realms/Digitalboard`
4. Client ID / Secret: from your Keycloak client
5. Add Group Role Mapping: Entra/Keycloak group Object ID → OpnForm role
Leave `opnform_admin_email` / `opnform_admin_password` empty. Visit
`opnform_base_url` and complete the setup page in the browser.
Direct DB manipulation of `identity_connections` / `group_role_mappings`
is possible but fragile across OpnForm versions. A future iteration of
this role may automate it.
## OIDC setup
Set `opnform_oidc_enabled: true` and the role creates an
IdentityConnection on the admin's default workspace via
`POST /api/open/workspaces/{id}/oidc-connections`. OpnForm enforces a
single OIDC connection per workspace, so the task is idempotent (GETs
existing connections first and skips if any exist).
**Prerequisite**: the admin bootstrap must be configured
(`opnform_admin_email` + `opnform_admin_password`). The OIDC API
requires an authenticated admin token; the role logs in with those
credentials to make the call. The validation block fails fast if OIDC
is enabled without admin credentials.
### Required when `opnform_oidc_enabled: true`
| Variable | Notes |
|---|---|
| `opnform_oidc_client_secret` | from your IdP, never commit |
| `opnform_oidc_domain` | email domain that triggers OIDC (e.g. `example.com`) |
### Tunables (defaults shown)
```yaml
opnform_oidc_issuer: "https://auth.digitalboard.ch/realms/Digitalboard"
opnform_oidc_client_id: "opnform-digitalboard"
opnform_oidc_client_name: "Digitalboard" # display name in UI
opnform_oidc_slug: "oidc" # used in /auth/{slug}/callback
opnform_oidc_scopes: [openid, profile, email, groups]
```
### Group → role mapping
Two ways, the list takes precedence:
```yaml
# Option 1: full list (any number of mappings)
opnform_oidc_group_role_mappings:
- idp_group: "opnform-admins"
role: admin
- idp_group: "opnform-editors"
role: editor
# Option 2: convenience — single admin group
opnform_oidc_admin_group: "opnform-admins" # mapped to role=admin
```
Valid roles: `owner`, `admin`, `editor`, `member`.
## Example playbook

View file

@ -38,6 +38,17 @@ opnform_db_name: "opnform"
opnform_db_user: "opnform"
opnform_db_password: "xtNLUVc2ajcWictqWXWkLR"
# Admin bootstrap — when email+password are set, the role creates the
# first user via OpnForm's /api/register endpoint, skipping the
# self-hosted setup page. Leave both empty to keep the manual setup flow.
# Password must satisfy OpnForm's rules: min 8 chars, contain a letter,
# a digit and one of @$!%*#?&-_+=.,:;<>^()[]{}|~
# Provide via OpenBao, Ansible Vault or extra-vars.
opnform_admin_name: "Administrator"
opnform_admin_email: ""
opnform_admin_password: ""
opnform_admin_hear_about_us: "ansible"
# PHP configuration
opnform_php_memory_limit: "1G"
opnform_php_max_execution_time: "600"
@ -57,14 +68,33 @@ opnform_mail_encryption: ""
opnform_mail_from_address: "noreply@digitalboard.ch"
opnform_mail_from_name: "OpnForm"
# OIDC configuration (Stage 1: not auto-configured, set up via UI after deploy)
# OIDC configuration — when enabled, the role auto-creates an
# IdentityConnection in the first workspace via OpnForm's API after the
# admin bootstrap. Requires opnform_admin_email/_password to be set
# (the API call needs an authenticated admin token).
opnform_oidc_enabled: false
opnform_oidc_issuer: "https://auth.digitalboard.ch/realms/Digitalboard"
opnform_oidc_client_id: "opnform-digitalboard"
opnform_oidc_client_secret: ""
opnform_oidc_client_name: "Digitalboard"
opnform_oidc_scopes: "openid profile email groups"
# OpnForm-side identifier used in /auth/{slug}/callback. Lowercase
# alphanumeric + hyphens, unique across all identity_connections.
opnform_oidc_slug: "oidc"
# Email domain that triggers OIDC login for matching users (e.g. users
# with @example.com emails are redirected to the IdP). Required when
# opnform_oidc_enabled is true.
opnform_oidc_domain: ""
opnform_oidc_scopes:
- openid
- profile
- email
- groups
# Convenience: maps a single IdP group to the OpnForm "admin" role.
# Ignored when opnform_oidc_group_role_mappings is non-empty.
opnform_oidc_admin_group: "opnform-admins"
# Full group-to-role mapping list. Takes precedence over the convenience
# var. Each item: {idp_group: "<group name>", role: "owner|admin|editor|member"}
opnform_oidc_group_role_mappings: []
# Traefik configuration
opnform_traefik_network: "proxy"

View file

@ -0,0 +1,265 @@
#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 (prefix 'base64:'), opnform_jwt_secret,
opnform_front_api_secret and opnform_db_password.
Generate with: openssl rand -base64 32
The app_key MUST be prefixed with "base64:"
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
- name: Deploy docker-compose file
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ opnform_docker_compose_dir }}/docker-compose.yml"
mode: '0644'
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.
- 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
- 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
- opnform_admin_login.status != 200
# =====================================================================
# 6. OIDC IDENTITY CONNECTION (optional)
# =====================================================================
# Creates a single OIDC connection on the admin's default workspace.
# OpnForm enforces one OIDC connection per workspace, so this block is
# idempotent: we GET existing connections first and skip if any exists.
- 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
- 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
- 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
- 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
- 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:
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 }}"
status_code: [201]
validate_certs: false
no_log: true
when:
- opnform_oidc_enabled | bool
- opnform_existing_oidc.json | length == 0
- 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.
{% else %}
OIDC: disabled (set opnform_oidc_enabled=true to auto-configure)
{% endif %}

View file

@ -3,4 +3,4 @@
- hosts: localhost
remote_user: root
roles:
- OpnForm
- opnform