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
parent 3f90843f97
commit 2341815daf
11 changed files with 366 additions and 145 deletions

View file

@ -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

View file

@ -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;
}
}