From 53e80ad7be1b8653f13cbc86fb08c84edfdb365e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20W=C3=BCst?= Date: Wed, 13 May 2026 17:23:34 +0200 Subject: [PATCH 1/5] chore: add new role for OpnForm --- roles/OpnForm/README.md | 126 ++++++++++++ roles/OpnForm/defaults/main.yml | 71 +++++++ roles/OpnForm/handlers/main.yml | 8 + roles/OpnForm/meta/main.yml | 35 ++++ roles/OpnForm/tasks/main.yml | 117 +++++++++++ roles/OpnForm/templates/docker-compose.yml.j2 | 189 ++++++++++++++++++ roles/OpnForm/templates/nginx.conf.j2 | 43 ++++ roles/OpnForm/tests/inventory | 2 + roles/OpnForm/tests/test.yml | 6 + roles/OpnForm/vars/main.yml | 3 + 10 files changed, 600 insertions(+) create mode 100644 roles/OpnForm/README.md create mode 100644 roles/OpnForm/defaults/main.yml create mode 100644 roles/OpnForm/handlers/main.yml create mode 100644 roles/OpnForm/meta/main.yml create mode 100644 roles/OpnForm/tasks/main.yml create mode 100644 roles/OpnForm/templates/docker-compose.yml.j2 create mode 100644 roles/OpnForm/templates/nginx.conf.j2 create mode 100644 roles/OpnForm/tests/inventory create mode 100644 roles/OpnForm/tests/test.yml create mode 100644 roles/OpnForm/vars/main.yml diff --git a/roles/OpnForm/README.md b/roles/OpnForm/README.md new file mode 100644 index 0000000..67e5436 --- /dev/null +++ b/roles/OpnForm/README.md @@ -0,0 +1,126 @@ +# opnform + +Deploy [OpnForm](https://github.com/OpnForm/OpnForm) as a self-contained +Docker Compose stack behind Traefik. + +## What this role does + +- Deploys the full official OpnForm stack: `api`, `api-worker`, `api-scheduler`, + `ui`, `db` (Postgres), `redis`, and `ingress` (nginx) +- Configures all environment variables for self-hosted production use +- Integrates the ingress container with an existing Traefik proxy network +- Waits for the API container to become healthy before returning + +## 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? + +``` +Browser → Traefik (TLS, host routing) → ingress-nginx → api (PHP-FPM) / ui (Nuxt) +``` + +The `ingress` container looks like a redundant proxy next to Traefik but +does a different job. OpnForm's `api` image is **PHP-FPM only** — it +speaks the FastCGI protocol on port 9000, not HTTP. Traefik cannot +translate FastCGI, so the ingress nginx is required to: + +- Translate HTTP `/api/*` requests into FastCGI calls to `api:9000` +- Rewrite request URIs via the `$api_uri` map +- Set Laravel-specific FastCGI params (`SCRIPT_FILENAME`, `REQUEST_URI`) +- Reverse-proxy `/` to the Nuxt UI container on port 3000 + +Both containers run on the same Docker network on the same host, so the +performance overhead of the extra hop is negligible (in-kernel memory +copy, not a real network round-trip). Removing the ingress would require +a custom OpnForm image with a built-in HTTP server, which is out of +scope for this role. + +## Required variables + +Provide via OpenBao, Ansible Vault, or extra-vars. **Never commit real +secrets to version control.** + +| Variable | Format | Generate with | +|---|---|---| +| `opnform_app_key` | `base64:<32 bytes base64>` | `echo "base64:$(openssl rand -base64 32)"` | +| `opnform_jwt_secret` | 32 bytes base64 | `openssl rand -base64 32` | +| `opnform_front_api_secret` | 32 bytes base64 | `openssl rand -base64 32` | +| `opnform_db_password` | strong password | `openssl rand -base64 24` | + +When `opnform_oidc_enabled` is `true`: + +| Variable | Source | +|---|---| +| `opnform_oidc_client_secret` | from your Keycloak/Authentik client | + +The `assert` task at the top of the role will fail fast if any secret is +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: + +- Email: `admin@opnform.com` +- Password: `password` + +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. + +### If the login does not respond + +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 +``` + +## OIDC setup (stage 2, not yet automated) + +Manual setup via the Admin UI is currently the supported path: + +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 + +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. + +## Example playbook + +```yaml +- name: Deploy OpnForm service + hosts: opnform_servers + become: true + roles: + - digitalboard.core.opnform +``` + +With inventory variables: + +```yaml +# group_vars/opnform_servers.yml +opnform_domain: forms.digitalboard.ch +opnform_base_url: "https://forms.digitalboard.ch" +opnform_app_key: "{{ lookup('community.hashi_vault.vault_kv2_get', + 'digitalboard/opnform', + mount_point='kv').data.data.app_key }}" +opnform_jwt_secret: "{{ lookup('community.hashi_vault.vault_kv2_get', + 'digitalboard/opnform', + mount_point='kv').data.data.jwt_secret }}" +opnform_front_api_secret: "{{ lookup('community.hashi_vault.vault_kv2_get', + 'digitalboard/opnform', + mount_point='kv').data.data.front_api_secret }}" +opnform_db_password: "{{ lookup('community.hashi_vault.vault_kv2_get', + 'digitalboard/opnform', + mount_point='kv').data.data.db_password }}" +``` diff --git a/roles/OpnForm/defaults/main.yml b/roles/OpnForm/defaults/main.yml new file mode 100644 index 0000000..35996a2 --- /dev/null +++ b/roles/OpnForm/defaults/main.yml @@ -0,0 +1,71 @@ +#SPDX-License-Identifier: MIT-0 +--- +# defaults file for opnform + +# Base directory configuration (inherited from base role or defined here) +docker_compose_base_dir: /etc/docker/compose +docker_volume_base_dir: /srv/data + +# opnform-specific configuration +opnform_service_name: opnform +opnform_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ opnform_service_name }}" +opnform_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ opnform_service_name }}" +opnform_storage_dir: "{{ opnform_docker_volume_dir }}/storage" +opnform_db_data_dir: "{{ opnform_docker_volume_dir }}/db" +opnform_redis_data_dir: "{{ opnform_docker_volume_dir }}/redis" + +# Service configuration +opnform_domain: "forms.local.test" +opnform_base_url: "https://forms.local.test" + +# Images +opnform_api_image: "jhumanj/opnform-api:latest" +opnform_client_image: "jhumanj/opnform-client:latest" +opnform_redis_image: "redis:7" +opnform_db_image: "postgres:16" +opnform_ingress_image: "nginx:1" + +# REQUIRED SECRETS — generate with: openssl rand -base64 32 +# Always prefix opnform_app_key with "base64:" +# Provide via OpenBao lookup, Ansible Vault or extra-vars. +# Never commit real keys to version control. +opnform_app_key: "base64:vsQw8EoC64nmhurLUUohXUlAeryaV6Y2Is64Tdvjlko=" +opnform_jwt_secret: "0b2e8ed326334a08ce3846bfcd6588f5a11be33999e96963cd4eaff1a3ae828b" +opnform_front_api_secret: "8f52397785a110b657f2a6beab13362877bfac936ae9002bc236c54ed1011b2d" + +# Database credentials +opnform_db_name: "opnform" +opnform_db_user: "opnform" +opnform_db_password: "xtNLUVc2ajcWictqWXWkLR" + +# PHP configuration +opnform_php_memory_limit: "1G" +opnform_php_max_execution_time: "600" +opnform_php_upload_max_filesize: "64M" +opnform_php_post_max_size: "64M" + +# Nginx ingress +opnform_nginx_max_body_size: "64m" + +# Mail configuration (optional — defaults to log driver) +opnform_mail_mailer: "log" +opnform_mail_host: "" +opnform_mail_port: "" +opnform_mail_username: "" +opnform_mail_password: "" +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) +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_oidc_admin_group: "opnform-admins" + +# Traefik configuration +opnform_traefik_network: "proxy" +opnform_use_ssl: true diff --git a/roles/OpnForm/handlers/main.yml b/roles/OpnForm/handlers/main.yml new file mode 100644 index 0000000..1c0b422 --- /dev/null +++ b/roles/OpnForm/handlers/main.yml @@ -0,0 +1,8 @@ +#SPDX-License-Identifier: MIT-0 +--- +# handlers file for opnform + +- name: restart opnform + community.docker.docker_compose_v2: + project_src: "{{ opnform_docker_compose_dir }}" + state: restarted diff --git a/roles/OpnForm/meta/main.yml b/roles/OpnForm/meta/main.yml new file mode 100644 index 0000000..faea947 --- /dev/null +++ b/roles/OpnForm/meta/main.yml @@ -0,0 +1,35 @@ +#SPDX-License-Identifier: MIT-0 +galaxy_info: + author: your name + description: your role description + company: your company (optional) + + # If the issue tracker for your role is not on github, uncomment the + # next line and provide a value + # issue_tracker_url: http://example.com/issue/tracker + + # Choose a valid license ID from https://spdx.org - some suggested licenses: + # - BSD-3-Clause (default) + # - MIT + # - GPL-2.0-or-later + # - GPL-3.0-only + # - Apache-2.0 + # - CC-BY-4.0 + license: license (GPL-2.0-or-later, MIT, etc) + + min_ansible_version: 2.2 + + # If this a Container Enabled role, provide the minimum Ansible Container version. + # min_ansible_container_version: + + galaxy_tags: [] + # List tags for your role here, one per line. A tag is a keyword that describes + # and categorizes the role. Users find roles by searching for tags. Be sure to + # remove the '[]' above, if you add tags to this list. + # + # NOTE: A tag is limited to a single word comprised of alphanumeric characters. + # Maximum 20 tags per role. + +dependencies: [] + # List your role dependencies here, one per line. Be sure to remove the '[]' above, + # if you add dependencies to this list. \ No newline at end of file diff --git a/roles/OpnForm/tasks/main.yml b/roles/OpnForm/tasks/main.yml new file mode 100644 index 0000000..412dc25 --- /dev/null +++ b/roles/OpnForm/tasks/main.yml @@ -0,0 +1,117 @@ +#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/templates/docker-compose.yml.j2 b/roles/OpnForm/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..de88a33 --- /dev/null +++ b/roles/OpnForm/templates/docker-compose.yml.j2 @@ -0,0 +1,189 @@ +#---------------------------------------------------------------------# +# OpnForm — Beautiful open-source form builder # +#---------------------------------------------------------------------# +services: + api: &api-service + image: {{ opnform_api_image }} + container_name: opnform-api + restart: unless-stopped + volumes: + - {{ opnform_storage_dir }}:/usr/share/nginx/html/storage:rw + environment: &api-env + APP_ENV: production + APP_KEY: "{{ opnform_app_key }}" + APP_URL: "{{ opnform_base_url }}" + APP_DEBUG: "false" + SELF_HOSTED: "true" + + LOG_CHANNEL: errorlog + LOG_LEVEL: info + + DB_CONNECTION: pgsql + DB_HOST: db + DB_PORT: "5432" + DB_DATABASE: "{{ opnform_db_name }}" + DB_USERNAME: "{{ opnform_db_user }}" + DB_PASSWORD: "{{ opnform_db_password }}" + + REDIS_HOST: redis + REDIS_PORT: "6379" + + CACHE_STORE: redis + CACHE_DRIVER: redis + QUEUE_CONNECTION: redis + SESSION_DRIVER: redis + SESSION_LIFETIME: "120" + BROADCAST_CONNECTION: log + + FILESYSTEM_DISK: local + FILESYSTEM_DRIVER: local + LOCAL_FILESYSTEM_VISIBILITY: public + + MAIL_MAILER: "{{ opnform_mail_mailer }}" + MAIL_HOST: "{{ opnform_mail_host }}" + MAIL_PORT: "{{ opnform_mail_port }}" + MAIL_USERNAME: "{{ opnform_mail_username }}" + MAIL_PASSWORD: "{{ opnform_mail_password }}" + MAIL_ENCRYPTION: "{{ opnform_mail_encryption }}" + MAIL_FROM_ADDRESS: "{{ opnform_mail_from_address }}" + MAIL_FROM_NAME: "{{ opnform_mail_from_name }}" + + JWT_TTL: "1440" + JWT_SECRET: "{{ opnform_jwt_secret }}" + + PHP_MEMORY_LIMIT: "{{ opnform_php_memory_limit }}" + PHP_MAX_EXECUTION_TIME: "{{ opnform_php_max_execution_time }}" + PHP_UPLOAD_MAX_FILESIZE: "{{ opnform_php_upload_max_filesize }}" + PHP_POST_MAX_SIZE: "{{ opnform_php_post_max_size }}" + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "php /usr/share/nginx/html/artisan about || exit 1"] + interval: 30s + timeout: 15s + retries: 3 + start_period: 60s + networks: + - opnform-internal + + api-worker: + <<: *api-service + container_name: opnform-api-worker + command: ["php", "artisan", "queue:work"] + environment: + <<: *api-env + IS_API_WORKER: "true" + healthcheck: + test: ["CMD-SHELL", "pgrep -f 'php artisan queue:work' > /dev/null || exit 1"] + interval: 60s + timeout: 10s + retries: 3 + start_period: 30s + + api-scheduler: + <<: *api-service + container_name: opnform-api-scheduler + command: ["php", "artisan", "schedule:work"] + healthcheck: + test: + - "CMD-SHELL" + - "php /usr/share/nginx/html/artisan app:scheduler-status --mode=check --max-minutes=3 || exit 1" + interval: 60s + timeout: 30s + retries: 3 + start_period: 70s + + ui: + image: {{ opnform_client_image }} + container_name: opnform-ui + restart: unless-stopped + environment: + NUXT_PUBLIC_APP_URL: "{{ opnform_base_url }}" + NUXT_PUBLIC_API_BASE: "/api" + NUXT_PRIVATE_API_BASE: "http://ingress/api" + NUXT_PUBLIC_ENV: production + FRONT_API_SECRET: "{{ opnform_front_api_secret }}" + depends_on: + api: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget --spider -q http://localhost:3000/login || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 45s + networks: + - opnform-internal + + redis: + image: {{ opnform_redis_image }} + container_name: opnform-redis + restart: unless-stopped + volumes: + - {{ opnform_redis_data_dir }}:/data + healthcheck: + test: ["CMD-SHELL", "redis-cli ping | grep PONG"] + interval: 30s + timeout: 5s + networks: + - opnform-internal + + db: + image: {{ opnform_db_image }} + container_name: opnform-db + restart: unless-stopped + environment: + POSTGRES_DB: "{{ opnform_db_name }}" + POSTGRES_USER: "{{ opnform_db_user }}" + POSTGRES_PASSWORD: "{{ opnform_db_password }}" + volumes: + - {{ opnform_db_data_dir }}:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U {{ opnform_db_user }}"] + interval: 30s + timeout: 5s + networks: + - opnform-internal + + ingress: + image: {{ opnform_ingress_image }} + container_name: opnform-ingress + restart: unless-stopped + volumes: + - ./nginx.conf:/etc/nginx/templates/default.conf.template:ro + environment: + NGINX_MAX_BODY_SIZE: "{{ opnform_nginx_max_body_size }}" + depends_on: + api: + condition: service_started + ui: + condition: service_started + healthcheck: + test: ["CMD-SHELL", "nginx -t && curl -f http://localhost/ || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + networks: + - opnform-internal + - {{ opnform_traefik_network }} + labels: + - traefik.enable=true + - traefik.docker.network={{ opnform_traefik_network }} + - traefik.http.routers.{{ opnform_service_name }}.rule=Host(`{{ opnform_domain }}`) +{% if opnform_use_ssl %} + - traefik.http.routers.{{ opnform_service_name }}.entrypoints=websecure + - traefik.http.routers.{{ opnform_service_name }}.tls=true +{% else %} + - traefik.http.routers.{{ opnform_service_name }}.entrypoints=web +{% endif %} + - traefik.http.services.{{ opnform_service_name }}.loadbalancer.server.port=80 + +networks: + opnform-internal: + driver: bridge + {{ opnform_traefik_network }}: + external: true diff --git a/roles/OpnForm/templates/nginx.conf.j2 b/roles/OpnForm/templates/nginx.conf.j2 new file mode 100644 index 0000000..fa3193b --- /dev/null +++ b/roles/OpnForm/templates/nginx.conf.j2 @@ -0,0 +1,43 @@ +map $original_uri $api_uri { + ~^/api(/.*$) $1; + default $original_uri; +} + +server { + listen 80; + server_name {{ opnform_domain }}; + root /app/public; + + client_max_body_size {% raw %}${NGINX_MAX_BODY_SIZE}{% endraw %}; + + access_log /dev/stdout; + error_log /dev/stderr error; + + index index.html index.htm index.php; + + location / { + proxy_http_version 1.1; + proxy_pass http://ui:3000; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + } + + location ~/(api|open|local\/temp|forms\/assets)/ { + set $original_uri $uri; + try_files $uri $uri/ /index.php$is_args$args; + } + + location ~ \.php$ { + fastcgi_split_path_info ^(.+\.php)(/.+)$; + fastcgi_pass api:9000; + fastcgi_index index.php; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME /usr/share/nginx/html/public/index.php; + fastcgi_param REQUEST_URI $api_uri; + } +} diff --git a/roles/OpnForm/tests/inventory b/roles/OpnForm/tests/inventory new file mode 100644 index 0000000..712db59 --- /dev/null +++ b/roles/OpnForm/tests/inventory @@ -0,0 +1,2 @@ +#SPDX-License-Identifier: MIT-0 +localhost diff --git a/roles/OpnForm/tests/test.yml b/roles/OpnForm/tests/test.yml new file mode 100644 index 0000000..60bdb75 --- /dev/null +++ b/roles/OpnForm/tests/test.yml @@ -0,0 +1,6 @@ +#SPDX-License-Identifier: MIT-0 +--- +- hosts: localhost + remote_user: root + roles: + - OpnForm \ No newline at end of file diff --git a/roles/OpnForm/vars/main.yml b/roles/OpnForm/vars/main.yml new file mode 100644 index 0000000..984df2b --- /dev/null +++ b/roles/OpnForm/vars/main.yml @@ -0,0 +1,3 @@ +#SPDX-License-Identifier: MIT-0 +--- +# vars file for homarr \ No newline at end of file From 03af64ca2c0933357720be382c45baa9ebe83ceb 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 2/5] 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 From 48d12a1b4a19367dbe3b35503dbff5aa2e019775 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Tue, 26 May 2026 14:58:10 +0200 Subject: [PATCH 3/5] fix(opnform): address review feedback on vars header and meta boilerplate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * vars/main.yml: header was 'vars file for homarr' (copy-paste from the homarr role). Fixed to 'vars file for opnform'. File body is empty. * meta/main.yml: replace ansible-galaxy init boilerplate with real metadata — author, description, license (MIT-0), min_ansible_version set to '2.15' as a string (galaxy schema requires str), galaxy_tags for discovery, and an empty dependencies list. The third inline finding (dead roles/opnform/templates/compose.yml.j2) is resolved by dropping the WIP commit a6f301e during the rebase rather than removing it in a separate commit — the file no longer exists in the rebased history. --- roles/opnform/meta/main.yml | 41 ++++++++++--------------------------- roles/opnform/vars/main.yml | 2 +- 2 files changed, 12 insertions(+), 31 deletions(-) diff --git a/roles/opnform/meta/main.yml b/roles/opnform/meta/main.yml index faea947..8a56a7b 100644 --- a/roles/opnform/meta/main.yml +++ b/roles/opnform/meta/main.yml @@ -1,35 +1,16 @@ #SPDX-License-Identifier: MIT-0 galaxy_info: - author: your name - description: your role description - company: your company (optional) + author: Tobias Wüst + description: Deploy OpnForm self-hosted form builder via Docker Compose behind Traefik + company: Digitalboard + license: MIT-0 + min_ansible_version: "2.15" - # If the issue tracker for your role is not on github, uncomment the - # next line and provide a value - # issue_tracker_url: http://example.com/issue/tracker - - # Choose a valid license ID from https://spdx.org - some suggested licenses: - # - BSD-3-Clause (default) - # - MIT - # - GPL-2.0-or-later - # - GPL-3.0-only - # - Apache-2.0 - # - CC-BY-4.0 - license: license (GPL-2.0-or-later, MIT, etc) - - min_ansible_version: 2.2 - - # If this a Container Enabled role, provide the minimum Ansible Container version. - # min_ansible_container_version: - - galaxy_tags: [] - # List tags for your role here, one per line. A tag is a keyword that describes - # and categorizes the role. Users find roles by searching for tags. Be sure to - # remove the '[]' above, if you add tags to this list. - # - # NOTE: A tag is limited to a single word comprised of alphanumeric characters. - # Maximum 20 tags per role. + galaxy_tags: + - opnform + - forms + - docker + - traefik + - oidc dependencies: [] - # List your role dependencies here, one per line. Be sure to remove the '[]' above, - # if you add dependencies to this list. \ No newline at end of file diff --git a/roles/opnform/vars/main.yml b/roles/opnform/vars/main.yml index 984df2b..94900f8 100644 --- a/roles/opnform/vars/main.yml +++ b/roles/opnform/vars/main.yml @@ -1,3 +1,3 @@ #SPDX-License-Identifier: MIT-0 --- -# vars file for homarr \ No newline at end of file +# vars file for opnform \ No newline at end of file From fb81f60f9d6259d8b0584d74ee0b13c9a043fef1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Tue, 26 May 2026 14:58:18 +0200 Subject: [PATCH 4/5] fix(opnform): drop production-looking secrets from defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit opnform_app_key, opnform_jwt_secret, opnform_front_api_secret and opnform_db_password shipped as real base64 strings in defaults — they look like production secrets that just happen to be public. Set all four to '' and rely on the existing Validate task (and the new argument_specs marking them required) to fail fast when an inventory forgets to override them. Mirror the docstring comment to show how to generate each one with openssl. --- roles/opnform/defaults/main.yml | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/roles/opnform/defaults/main.yml b/roles/opnform/defaults/main.yml index 09aed4c..0f61c3a 100644 --- a/roles/opnform/defaults/main.yml +++ b/roles/opnform/defaults/main.yml @@ -25,18 +25,26 @@ opnform_redis_image: "redis:7" opnform_db_image: "postgres:16" opnform_ingress_image: "nginx:1" -# REQUIRED SECRETS — generate with: openssl rand -base64 32 -# Always prefix opnform_app_key with "base64:" +# REQUIRED SECRETS — must be overridden per-inventory. # Provide via OpenBao lookup, Ansible Vault or extra-vars. # Never commit real keys to version control. -opnform_app_key: "base64:vsQw8EoC64nmhurLUUohXUlAeryaV6Y2Is64Tdvjlko=" -opnform_jwt_secret: "0b2e8ed326334a08ce3846bfcd6588f5a11be33999e96963cd4eaff1a3ae828b" -opnform_front_api_secret: "8f52397785a110b657f2a6beab13362877bfac936ae9002bc236c54ed1011b2d" +# +# Generate with: +# opnform_app_key: echo "base64:$(openssl rand -base64 32)" +# opnform_jwt_secret: openssl rand -hex 32 +# opnform_front_api_secret: openssl rand -hex 32 +# +# opnform_app_key MUST start with the prefix "base64:" — the validate +# task at the top of tasks/main.yml enforces this. +opnform_app_key: "" +opnform_jwt_secret: "" +opnform_front_api_secret: "" -# Database credentials +# Database credentials. opnform_db_password must be overridden; the +# validate task fails fast on an empty value. opnform_db_name: "opnform" opnform_db_user: "opnform" -opnform_db_password: "xtNLUVc2ajcWictqWXWkLR" +opnform_db_password: "" # Admin bootstrap — when email+password are set, the role creates the # first user via OpnForm's /api/register endpoint, skipping the From 30f3c16b59de8aa2362f547730eb4a883c45895c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Tue, 26 May 2026 14:58:36 +0200 Subject: [PATCH 5/5] docs(opnform): add meta/argument_specs.yml 50 typed options covering the full defaults file plus the OIDC subschema (group_role_mappings with idp_group + role choices). Required secrets (app_key, jwt_secret, front_api_secret, db_password) marked required: true so ansible refuses the play with a clear error before the validate task even runs. Loads cleanly through ansible-core's ArgumentSpecValidator. Matches the spec convention introduced for traefik, authentik, drawio, garage and nextcloud. --- roles/opnform/meta/argument_specs.yml | 220 ++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 roles/opnform/meta/argument_specs.yml diff --git a/roles/opnform/meta/argument_specs.yml b/roles/opnform/meta/argument_specs.yml new file mode 100644 index 0000000..9fbfc7a --- /dev/null +++ b/roles/opnform/meta/argument_specs.yml @@ -0,0 +1,220 @@ +--- +argument_specs: + main: + short_description: Deploy OpnForm (api + ui + db + redis + ingress) via Docker Compose. + description: + - Renders a Compose stack for the full OpnForm setup (PHP-FPM api, + Nuxt ui, Postgres, Redis, nginx ingress) and exposes it through + Traefik. + - Optionally bootstraps the first admin user via the OpnForm + C(/api/register) endpoint (skipping the self-hosted setup page) + and provisions a single OIDC identity connection in the default + workspace via the workspace API. Both bootstraps are idempotent. + options: + docker_compose_base_dir: + type: path + default: /etc/docker/compose + docker_volume_base_dir: + type: path + default: /srv/data + opnform_service_name: + type: str + default: opnform + opnform_docker_compose_dir: + type: path + description: Defaults to C({{ docker_compose_base_dir }}/{{ opnform_service_name }}). + opnform_docker_volume_dir: + type: path + description: Defaults to C({{ docker_volume_base_dir }}/{{ opnform_service_name }}). + opnform_storage_dir: + type: path + description: OpnForm storage volume mounted into the api container. + opnform_db_data_dir: + type: path + opnform_redis_data_dir: + type: path + + opnform_domain: + type: str + default: forms.local.test + description: Hostname used in the traefik Host rule. + opnform_base_url: + type: str + default: https://forms.local.test + description: Public URL OpnForm uses for APP_URL and NUXT_PUBLIC_APP_URL. + + opnform_api_image: + type: str + default: jhumanj/opnform-api:latest + opnform_client_image: + type: str + default: jhumanj/opnform-client:latest + opnform_redis_image: + type: str + default: "redis:7" + opnform_db_image: + type: str + default: "postgres:16" + opnform_ingress_image: + type: str + default: "nginx:1" + + opnform_app_key: + type: str + required: true + description: + - Laravel application key. Must be prefixed with C(base64:). + Generate with C(echo "base64:$(openssl rand -base64 32)"). + Provide via OpenBao, Ansible Vault or extra-vars. + opnform_jwt_secret: + type: str + required: true + description: JWT signing secret. Generate with C(openssl rand -hex 32). + opnform_front_api_secret: + type: str + required: true + description: Shared secret between ui and api. Generate with C(openssl rand -hex 32). + + opnform_db_name: + type: str + default: opnform + opnform_db_user: + type: str + default: opnform + opnform_db_password: + type: str + required: true + + opnform_admin_name: + type: str + default: Administrator + opnform_admin_email: + type: str + default: '' + description: + - When non-empty (together with C(opnform_admin_password)) the role + bootstraps the first user via C(/api/register), skipping the + self-hosted setup page. Required when C(opnform_oidc_enabled=true). + opnform_admin_password: + type: str + default: '' + description: + - "Must satisfy OpnForm's policy: min 8 chars, letter + digit + + symbol from C(@$!%*#?&-_+=.,:;<>^()[]{}|~)." + opnform_admin_hear_about_us: + type: str + default: ansible + + opnform_php_memory_limit: + type: str + default: 1G + opnform_php_max_execution_time: + type: str + default: "600" + opnform_php_upload_max_filesize: + type: str + default: 64M + opnform_php_post_max_size: + type: str + default: 64M + opnform_nginx_max_body_size: + type: str + default: 64m + + opnform_mail_mailer: + type: str + default: log + choices: [log, smtp, ses, mailgun, postmark, sendmail] + opnform_mail_host: + type: str + default: '' + opnform_mail_port: + type: str + default: '' + opnform_mail_username: + type: str + default: '' + opnform_mail_password: + type: str + default: '' + opnform_mail_encryption: + type: str + default: '' + choices: ['', tls, ssl] + opnform_mail_from_address: + type: str + default: noreply@digitalboard.ch + opnform_mail_from_name: + type: str + default: OpnForm + + opnform_oidc_enabled: + type: bool + default: false + description: + - "When true the role calls the workspace API to create a single + OIDC C(identity_connection) on the default workspace after the + admin bootstrap. Requires C(opnform_admin_email) + + C(opnform_admin_password) so the role can authenticate. + Idempotent: skipped when any connection already exists." + opnform_oidc_issuer: + type: str + default: https://auth.digitalboard.ch/realms/Digitalboard + description: OIDC issuer URL. + opnform_oidc_client_id: + type: str + default: opnform-digitalboard + opnform_oidc_client_secret: + type: str + default: '' + description: Required when C(opnform_oidc_enabled=true). + opnform_oidc_client_name: + type: str + default: Digitalboard + description: Display name shown in the OpnForm UI. + opnform_oidc_slug: + type: str + default: oidc + description: + - OpnForm-side identifier used in C(/auth/{slug}/callback). Lowercase + alphanumeric + hyphens, unique across all C(identity_connections). + opnform_oidc_domain: + type: str + default: '' + description: + - Email domain that triggers OIDC for matching users. Required + when C(opnform_oidc_enabled=true). + opnform_oidc_scopes: + type: list + elements: str + default: [openid, profile, email, groups] + opnform_oidc_admin_group: + type: str + default: opnform-admins + description: + - Convenience setting that maps a single IdP group to the OpnForm + C(admin) role. Ignored when C(opnform_oidc_group_role_mappings) + is non-empty. + opnform_oidc_group_role_mappings: + type: list + elements: dict + default: [] + description: + - Full IdP-group -> OpnForm-role mapping. Takes precedence over + C(opnform_oidc_admin_group). + options: + idp_group: + type: str + required: true + description: Group name as it appears in the IdP groups claim. + role: + type: str + required: true + choices: [owner, admin, editor, member] + + opnform_traefik_network: + type: str + default: proxy + opnform_use_ssl: + type: bool + default: true