From 2341815daf33d1b757305a8d5b3032cfd89e252a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20W=C3=BCst?= Date: Mon, 18 May 2026 22:40:19 +0200 Subject: [PATCH] feat(opnform)!: add admin and OIDC bootstrap, rename role to lowercase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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]. --- roles/OpnForm/tasks/main.yml | 117 -------- roles/{OpnForm => opnform}/README.md | 93 ++++-- roles/{OpnForm => opnform}/defaults/main.yml | 34 ++- roles/{OpnForm => opnform}/handlers/main.yml | 0 roles/{OpnForm => opnform}/meta/main.yml | 0 roles/opnform/tasks/main.yml | 265 ++++++++++++++++++ .../templates/docker-compose.yml.j2 | 0 .../templates/nginx.conf.j2 | 0 roles/{OpnForm => opnform}/tests/inventory | 0 roles/{OpnForm => opnform}/tests/test.yml | 2 +- roles/{OpnForm => opnform}/vars/main.yml | 0 11 files changed, 366 insertions(+), 145 deletions(-) delete mode 100644 roles/OpnForm/tasks/main.yml rename roles/{OpnForm => opnform}/README.md (55%) rename roles/{OpnForm => opnform}/defaults/main.yml (61%) rename roles/{OpnForm => opnform}/handlers/main.yml (100%) rename roles/{OpnForm => opnform}/meta/main.yml (100%) create mode 100644 roles/opnform/tasks/main.yml rename roles/{OpnForm => opnform}/templates/docker-compose.yml.j2 (100%) rename roles/{OpnForm => opnform}/templates/nginx.conf.j2 (100%) rename roles/{OpnForm => opnform}/tests/inventory (100%) rename roles/{OpnForm => opnform}/tests/test.yml (86%) rename roles/{OpnForm => opnform}/vars/main.yml (100%) diff --git a/roles/OpnForm/tasks/main.yml b/roles/OpnForm/tasks/main.yml deleted file mode 100644 index 412dc25..0000000 --- a/roles/OpnForm/tasks/main.yml +++ /dev/null @@ -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 %} diff --git a/roles/OpnForm/README.md b/roles/opnform/README.md similarity index 55% rename from roles/OpnForm/README.md rename to roles/opnform/README.md index 67e5436..2dfad2d 100644 --- a/roles/OpnForm/README.md +++ b/roles/opnform/README.md @@ -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 diff --git a/roles/OpnForm/defaults/main.yml b/roles/opnform/defaults/main.yml similarity index 61% rename from roles/OpnForm/defaults/main.yml rename to roles/opnform/defaults/main.yml index 35996a2..09aed4c 100644 --- a/roles/OpnForm/defaults/main.yml +++ b/roles/opnform/defaults/main.yml @@ -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: "", role: "owner|admin|editor|member"} +opnform_oidc_group_role_mappings: [] # Traefik configuration opnform_traefik_network: "proxy" diff --git a/roles/OpnForm/handlers/main.yml b/roles/opnform/handlers/main.yml similarity index 100% rename from roles/OpnForm/handlers/main.yml rename to roles/opnform/handlers/main.yml diff --git a/roles/OpnForm/meta/main.yml b/roles/opnform/meta/main.yml similarity index 100% rename from roles/OpnForm/meta/main.yml rename to roles/opnform/meta/main.yml diff --git a/roles/opnform/tasks/main.yml b/roles/opnform/tasks/main.yml new file mode 100644 index 0000000..68e093b --- /dev/null +++ b/roles/opnform/tasks/main.yml @@ -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 %} diff --git a/roles/OpnForm/templates/docker-compose.yml.j2 b/roles/opnform/templates/docker-compose.yml.j2 similarity index 100% rename from roles/OpnForm/templates/docker-compose.yml.j2 rename to roles/opnform/templates/docker-compose.yml.j2 diff --git a/roles/OpnForm/templates/nginx.conf.j2 b/roles/opnform/templates/nginx.conf.j2 similarity index 100% rename from roles/OpnForm/templates/nginx.conf.j2 rename to roles/opnform/templates/nginx.conf.j2 diff --git a/roles/OpnForm/tests/inventory b/roles/opnform/tests/inventory similarity index 100% rename from roles/OpnForm/tests/inventory rename to roles/opnform/tests/inventory diff --git a/roles/OpnForm/tests/test.yml b/roles/opnform/tests/test.yml similarity index 86% rename from roles/OpnForm/tests/test.yml rename to roles/opnform/tests/test.yml index 60bdb75..3ff9caa 100644 --- a/roles/OpnForm/tests/test.yml +++ b/roles/opnform/tests/test.yml @@ -3,4 +3,4 @@ - hosts: localhost remote_user: root roles: - - OpnForm \ No newline at end of file + - opnform \ No newline at end of file diff --git a/roles/OpnForm/vars/main.yml b/roles/opnform/vars/main.yml similarity index 100% rename from roles/OpnForm/vars/main.yml rename to roles/opnform/vars/main.yml