digitalboard.core/roles/opnform/README.md
Simon Bärlocher 3ace667b6c
feat(services): refine split-horizon OIDC routing and harden nextcloud patch
- authentik: address the rewrite service by compose service name instead
  of a network alias on the public FQDN, which shadowed extra_hosts pins
  and broke OIDC discovery for c-ares-based (Node) resolvers
- homarr: add homarr_extra_hosts to pin the IdP FQDN to a LAN IP so OIDC
  discovery stays in-network while the issuer matches the browser-facing URL
- opnform: add opnform_oidc_sso_redirect_root to 302 the root URL to the
  SSO path (deep-links untouched, /login?bypass=1 break-glass); restart
  ingress via container restart so envsubst re-renders nginx.conf
- nextcloud: make the UserConfig sed workaround fail loud on upstream
  drift instead of silently skipping (nextcloud/server#59629)
- gitignore: exclude the local .ansible/ collection cache
2026-06-02 13:44:08 +02:00

225 lines
8.3 KiB
Markdown

# 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
- Does not migrate existing OpnForm databases — only bootstraps fresh
installs (admin registration + OIDC connection are idempotent)
## 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-byte hex string | `openssl rand -hex 32` |
| `opnform_front_api_secret` | 32-byte hex string | `openssl rand -hex 32` |
| `opnform_db_password` | strong password | `openssl rand -base64 24` |
`opnform_app_key` MUST keep the `base64:` prefix — the validation task
asserts it. `opnform_jwt_secret` and `opnform_front_api_secret` have no
enforced format; any sufficiently random value works.
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
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).
This role supports two ways to create that first user:
### Option A — automated bootstrap (recommended)
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.
```yaml
opnform_admin_name: "Administrator" # default
opnform_admin_email: "admin@example.com"
opnform_admin_password: "{{ vault_opnform_admin_password }}"
```
Password rules enforced by OpnForm: minimum 8 characters, at least one
letter, one digit, and one of `@$!%*#?&-_+=.,:;<>^()[]{}|~`.
### Option B — manual setup page
Leave `opnform_admin_email` / `opnform_admin_password` empty. Visit
`opnform_base_url` and complete the setup page in the browser.
## OIDC setup
Set `opnform_oidc_enabled: true` and the role provisions 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: it GETs
existing connections first, then either POSTs a new one or PATCHes the
existing one to the desired state. PATCHing (rather than skipping when
one exists) keeps inventory changes — e.g. a corrected issuer — applied
on re-runs instead of leaving stale values in the DB.
**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`.
### Force OIDC-only login
```yaml
opnform_oidc_force_login: true # default false
```
Sets `OIDC_FORCE_LOGIN=true` on the API: password login is disabled and
every user must authenticate via OIDC. The role keeps force-login **off**
during the first deploy (the admin/OIDC bootstrap is password-based) and
switches it on only after the OIDC connection is provisioned, recreating
the API containers. Ensure all real users have addresses under
`opnform_oidc_domain` before enabling — there is no password fallback.
### Direct-SSO entrypoint
OpnForm has no native way to skip the email login form and jump straight
to the IdP. When enabled, the ingress serves a tiny redirect page that
calls `/api/auth/{slug}/redirect` (no domain check) and forwards the
browser to the IdP authorize URL.
```yaml
opnform_oidc_sso_entrypoint: true # default false
opnform_oidc_sso_path: "/sso" # link users to https://<domain>/sso
opnform_oidc_sso_redirect_root: true # default false — root URL 302s to <sso_path>
```
With `opnform_oidc_sso_redirect_root` enabled both the bare hostname
and `/login` jump straight to the IdP. Public form deep-links
(`/forms/<slug>`, `/admin/...`) are not touched. The email form remains
reachable as a break-glass path via `/login?bypass=1`.
## Networking / split-horizon
```yaml
opnform_extra_domains: [] # extra Host-rule hostnames (OR-combined)
opnform_extra_hosts: [] # API container /etc/hosts overrides ("host:ip")
```
`opnform_extra_domains` adds internal `*.int.*` FQDNs so a DMZ
reverseproxy can reach a backend hostname covered by the cert.
`opnform_extra_hosts` lets the API containers reach the IdP's public FQDN
(used in the OIDC `iss` claim) over the LAN when the DMZ has no NAT
loopback.
## 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 }}"
```
## License
MIT-0