Files
computer-playbook/docs/requirements/028-web-app-semaphore.md
kevinveenbirkenbachandClaude Opus 5 f40d627a1b docs(agents): merge the tool-specific files into AGENTS.md
CLAUDE.md and GEMINI.md existed as per-runtime extensions of AGENTS.md, and
only one of them carried anything at all: GEMINI.md stated that it had no
additions, while CLAUDE.md held rules that bind every runtime anyway — the
permission announcement, the make-over-raw preference, the loop, chain and
heredoc bans, the stream-the-log-to-/tmp rule, the CI-evidence rule, the push
ban and the comment rule. A rule that binds every agent does not belong in one
agent's file. AGENTS.md absorbs them and now states that no tool-specific
extension file exists; agents whose runtime auto-loads a different filename
reach it through their own configuration instead.

The filenames were machinery as much as prose. The PR templates, the branch
and pull-request scope tables, the settings and security docs that justified
the loop deny by "the textual CLAUDE.md rule", six requirement documents whose
push instruction linked ../../CLAUDE.md, and a test docstring citing the
shell-loop ban all named them, and now name AGENTS.md.
scripts/meta/resolve/pr/scope.sh deliberately keeps matching CLAUDE.md and
GEMINI.md: this change deletes them, and without those patterns the change
itself would not classify as agent-scope.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 22:51:04 +02:00

227 lines
17 KiB
Markdown

# 028 - Semaphore UI Role with OIDC SSO + LDAP
## User Story
As a platform administrator of Infinito.Nexus, I want [Semaphore UI](https://github.com/semaphoreui/semaphore) integrated as a `web-app-semaphore` role with OpenID Connect (Keycloak) SSO and optional LDAP login so that operators can run Ansible / Terraform / OpenTofu / Bash automation from a web UI behind the platform's central Single Sign-On, reusing the central database, and validated end-to-end with Playwright personas.
## Background
[Semaphore UI](https://semaphoreui.com/) (formerly *Ansible Semaphore*, repo `semaphoreui/semaphore`) is an open-source, Go-based web UI and API for running Ansible playbooks, Terraform/OpenTofu plans, and shell scripts on a schedule or on demand. It ships a single official container image `semaphoreui/semaphore` and is configured almost entirely through `SEMAPHORE_*` environment variables (preferred for containers) or a mounted `config.json`.
### Upstream facts established by research
| Topic | Finding | Source |
|---|---|---|
| Image | `semaphoreui/semaphore` (Docker Hub + `ghcr.io`); pin a concrete stable `2.x` semver (no `:latest`). | [Docker docs](https://docs.semaphoreui.com/administration-guide/installation/docker), [Docker Hub](https://hub.docker.com/r/semaphoreui/semaphore) |
| Config model | All settings available as `SEMAPHORE_*` env vars; `_FILE` suffix reads a value from a file (secret-friendly). | [env-vars](https://semaphoreui.com/docs/admin-guide/configuration/env-vars) |
| Database | `SEMAPHORE_DB_DIALECT` ∈ `bolt` (file at `/var/lib/semaphore`), `mysql`, `postgres`. Postgres uses `SEMAPHORE_DB_HOST/PORT/USER/PASS/SEMAPHORE_DB`. | [Configuration](https://semaphoreui.com/docs/admin-guide/configuration) |
| Admin bootstrap | `SEMAPHORE_ADMIN`, `SEMAPHORE_ADMIN_PASSWORD`, `SEMAPHORE_ADMIN_NAME`, `SEMAPHORE_ADMIN_EMAIL` seed the first admin (no setup wizard). | [Docker docs](https://docs.semaphoreui.com/administration-guide/installation/docker) |
| Secrets | `SEMAPHORE_ACCESS_KEY_ENCRYPTION` (base64 32 bytes, `head -c32 /dev/urandom \| base64`) encrypts stored access keys; `SEMAPHORE_COOKIE_HASH` / `SEMAPHORE_COOKIE_ENCRYPTION` secure sessions. | [Configuration](https://semaphoreui.com/docs/admin-guide/configuration) |
| OIDC (native) | `SEMAPHORE_OIDC_PROVIDERS` = JSON map. Per-provider: `display_name`, `provider_url` (issuer), `client_id`, `client_secret`, `redirect_url`. Redirect path is `https://<host>/api/auth/oidc/<provider_id>/redirect`. **Semaphore has native OIDC — no `oauth2-proxy` sidecar needed.** | [OpenID docs](https://docs.semaphoreui.com/administration-guide/openid/), [Keycloak config](https://docs.semaphoreui.com/administration-guide/openid/keycloak/) |
| LDAP (native) | `SEMAPHORE_LDAP_ACTIVATED`, `SEMAPHORE_LDAP_HOST`, `SEMAPHORE_LDAP_PORT`, `SEMAPHORE_LDAP_NEEDTLS`, `SEMAPHORE_LDAP_DN_BIND`, `SEMAPHORE_LDAP_PASSWORD`, `SEMAPHORE_LDAP_DN_SEARCH`, `SEMAPHORE_LDAP_SEARCH_FILTER`, plus attribute mappings (`SEMAPHORE_LDAP_MAPPING_DN/UID/MAIL/CN`). | [LDAP discussion #2106](https://github.com/semaphoreui/semaphore/discussions/2106) |
| Reverse proxy | `SEMAPHORE_WEB_ROOT` sets the externally-reachable base URL; required for correct OIDC redirects behind `sys-svc-proxy`. | [Configuration](https://semaphoreui.com/docs/admin-guide/configuration) |
| Runner | A built-in (embedded) runner executes tasks in the main container; external `semaphore runner` agents are optional for scale-out. | [Docker docs](https://docs.semaphoreui.com/administration-guide/installation/docker) |
| Auth precedence | When OIDC and/or LDAP are enabled, local password login is deprioritised; users authenticate via the configured provider(s). | [OpenID docs](https://docs.semaphoreui.com/administration-guide/openid/) |
### In-repo analogues
- SSO **flavor is `oidc` (native)**, not `oauth2-proxy` — same pattern as [`web-app-erpnext`](024-web-app-erpnext.md) and `web-app-odoo`: the app owns its "Login with Keycloak" button.
- Central database consumer + LDAP + OIDC + 3-variant matrix shape: closest structural template is [`web-app-openproject`](../../roles/web-app-openproject/) (Postgres consumer, `vars/ldap.yml`, OIDC, three personas).
- Keycloak OIDC client is **auto-provisioned** by `web-app-keycloak` for every OIDC consumer; no manual operator step.
## Confirmed Decisions
These decisions were confirmed by the operator before implementation starts and are NOT subject to re-litigation during implementation.
| # | Decision | Rationale |
|---|---|---|
| 1 | Canonical hostname `semaphore.{{ DOMAIN_PRIMARY }}` (alias-free v1). | Standard single-host convention for a new web-app role. |
| 2 | SSO flavor is **`oidc` (native)** — Semaphore renders its own "Sign in with Keycloak" button via `SEMAPHORE_OIDC_PROVIDERS`; the Keycloak client is auto-provisioned by `web-app-keycloak`. No `oauth2-proxy` sidecar. | Keeps Semaphore's API/runner token flow and its native auth UX, and lets LDAP coexist. |
| 3 | Database backend is **central PostgreSQL via `svc-db-postgres`** (`SEMAPHORE_DB_DIALECT=postgres`). When `shared=true` the role consumes the central engine; when `shared=false` `sys-svc-rdbms` templates a per-role Postgres. | Matches the platform convention used by [`web-app-openproject`](../../roles/web-app-openproject/); centralised backup + monitoring. |
| 4 | Runner topology is a **single all-in-one container** with the embedded runner. | Smallest v1 surface; matches the upstream default Docker compose. External runner agents are a follow-up. |
| 5 | Image `semaphoreui/semaphore` pinned to a concrete latest stable `2.x` semver (no `:latest`). | Standard upstream-pin policy across roles. |
| 6 | **Admin / role model = both** (operator: "admin anlegen wenn nicht oidc sonst claim mapping"), implemented within Semaphore's constraints: (a) a **break-glass local admin** is always seeded via `SEMAPHORE_ADMIN*` with a dedicated `breakglass` login + dedicated email — reachable via the form only when LDAP is off; (b) when SSO is enabled the role creates the Keycloak administrator as an **external admin** at deploy time (`semaphore user add --admin --external`), so the OIDC callback (matches by email, accepts only external users) elevates that operator to admin. Upstream has no claim→role mapping, so this deploy-time external-admin seed is the "smallest reconciliation step". | Verified against the v2.18.12 source: the login form is LDAP-only when `ldap_enable=true`, and the OIDC callback rejects local-user email matches and never sets admin from claims. |
## Target Schema
### Role layout
```
roles/web-app-semaphore/
├── README.md
├── files/
│ └── playwright/
│ ├── test-guest.js
│ ├── test-login-oidc-biber.js
│ ├── test-login-oidc-administrator.js
│ ├── test-login-ldap-biber.js
│ └── test-login-native-administrator.js
├── meta/
│ ├── main.yml
│ ├── info.yml
│ ├── server.yml
│ ├── services.yml
│ ├── secrets.yml
│ ├── users.yml
│ ├── rbac.yml
│ ├── variants.yml
│ └── volumes.yml
├── tasks/
│ ├── main.yml
│ └── 01_core.yml
├── templates/
│ ├── compose.yml.j2
│ ├── env.j2
│ └── playwright.env.j2
└── vars/
└── main.yml
```
### `meta/services.yml` excerpt (native OIDC, no oauth2-proxy)
```yaml
---
sso:
enabled: "{{ 'web-app-keycloak' in group_names }}"
shared: "{{ 'web-app-keycloak' in group_names }}"
flavor: oidc
ldap:
enabled: "{{ 'svc-db-openldap' in group_names }}"
shared: "{{ 'svc-db-openldap' in group_names }}"
logout:
enabled: "{{ 'web-svc-logout' in group_names }}"
shared: "{{ 'web-svc-logout' in group_names }}"
dashboard:
enabled: "{{ 'web-app-dashboard' in group_names }}"
shared: "{{ 'web-app-dashboard' in group_names }}"
# nocheck: playwright-service-flag — DB engine, covered by role-local integration tests
postgres: # (per Q1; swap for mariadb/bolt if chosen)
enabled: true # nocheck: dynamic-flag
shared: "{{ 'svc-db-postgres' in group_names }}"
semaphore:
image: semaphoreui/semaphore
version: "X.Y.Z" # latest stable 2.x at PR time
name: semaphore
ports:
local:
http: <free port>
run_after:
- web-app-keycloak
- svc-db-postgres
lifecycle: alpha
```
### `meta/variants.yml` (three variants, mirroring openproject)
V1 = `sso` + `ldap` both enabled; V2 = all dynamic flags `false`; V3 = `ldap` only.
## Acceptance Criteria
### Routing & TLS
- [ ] `semaphore.{{ DOMAIN_PRIMARY }}` resolves through `sys-svc-proxy` to the Semaphore container and `GET /` returns HTTP 200 with the Semaphore SPA shell.
- [ ] `SEMAPHORE_WEB_ROOT` is set to the canonical `https://` URL so OIDC redirect URLs resolve correctly behind the proxy.
- [ ] The role's `Content-Security-Policy` response header is emitted on the canonical front page and lists any enabled injector hosts.
### Role layout & image
- [ ] `roles/web-app-semaphore/` exists with the layout in [Target Schema](#role-layout).
- [ ] `meta/services.yml` pins `semaphoreui/semaphore` to a concrete stable `2.x` semver (no `:latest`).
- [ ] `meta/info.yml`, `server.yml`, `main.yml`, `secrets.yml`, `users.yml`, `rbac.yml`, `variants.yml`, `volumes.yml` exist and pass the repo's role-meta lint.
### Database (Decision #3 — central PostgreSQL)
- [ ] `SEMAPHORE_DB_DIALECT=postgres` plus `SEMAPHORE_DB_HOST/PORT/USER/PASS/SEMAPHORE_DB` are wired; when `services.postgres.shared=true` the role consumes the central `svc-db-postgres` (no per-role engine), else `sys-svc-rdbms` templates a per-role Postgres.
- [ ] On a fresh deploy the schema is auto-migrated and the stack reaches a steady running state.
### First-admin bootstrap (Decision #6 — break-glass local admin)
- [ ] `SEMAPHORE_ADMIN`, `SEMAPHORE_ADMIN_PASSWORD`, `SEMAPHORE_ADMIN_NAME`, `SEMAPHORE_ADMIN_EMAIL` seed a break-glass local admin (dedicated `breakglass` login + dedicated email, no collision with the SSO/LDAP identities) in **every** variant; no setup wizard is presented on first load. Its form login is reachable only when LDAP is disabled (Semaphore forces LDAP-only form auth otherwise).
- [ ] `SEMAPHORE_ACCESS_KEY_ENCRYPTION` and the cookie hash/encryption secrets are generated once and persisted in the role's secret store (idempotent across redeploys).
### SSO / OIDC (Decisions #2, #6)
- [ ] When `web-app-keycloak` is in `group_names`, the Keycloak OIDC client for Semaphore is auto-provisioned with redirect URI `https://semaphore.<DOMAIN_PRIMARY>/api/auth/oidc/keycloak/redirect`.
- [ ] `SEMAPHORE_OIDC_PROVIDERS` is rendered as valid JSON (`keycloak` provider: `display_name`, `provider_url`=realm issuer, `client_id`, `client_secret`, `redirect_url`).
- [ ] When OIDC is enabled, the role creates the Keycloak `administrator` as an external Semaphore admin at deploy time (`semaphore user add --admin --external`, matching `preferred_username` + email), so the OIDC callback elevates that operator to admin (verified for the `administrator` persona). Upstream has no native claim→admin mapping; this deploy-time external-admin seed is the documented smallest reconciliation step (README).
- [ ] A Playwright OIDC flow (biber + administrator) clicks "Sign in with Keycloak", completes the Keycloak chain, and lands on an authenticated Semaphore surface (administrator persona additionally asserts the admin-only `/users` surface).
### LDAP (V3 + V1 dual)
- [ ] When `svc-db-openldap` is in `group_names`, `SEMAPHORE_LDAP_ENABLE=true` plus `SEMAPHORE_LDAP_SERVER` (host:port), `SEMAPHORE_LDAP_BIND_DN` / `_BIND_PASSWORD`, `SEMAPHORE_LDAP_SEARCH_DN` / `_SEARCH_FILTER`, and the `SEMAPHORE_LDAP_MAPPING_*` attributes are rendered from the central LDAP vars (env names per the v2.x config schema).
- [ ] An LDAP-bind login for `biber` succeeds and lands on an authenticated surface (covered by a dedicated Playwright scenario gated on `ldap`).
- [ ] When both OIDC and LDAP are enabled (V1), OIDC is the primary button and LDAP login still works; the deploy is clean.
### Variants
- [ ] `meta/variants.yml` defines exactly three variants: V1 sso+ldap, V2 all-false, V3 ldap-only.
- [ ] All three variants deploy cleanly on a fresh box (full matrix gate, all PLAY RECAPs `failed=0`).
### Playwright (per [playwright.spec.js contract](../contributing/artefact/files/role/playwright.specs.js.md))
- [ ] `files/playwright/test-guest.js`: guest never reaches an authenticated surface; empty-credentials submission is rejected by the IdP.
- [ ] `files/playwright/test-login-oidc-biber.js` and `test-login-oidc-administrator.js`: OIDC personas reach an authenticated surface, drive a role-specific interaction (e.g. open the Projects list / admin Users panel), and log out to a verified unauthenticated landing. Gated on `skipUnlessServiceEnabled('sso')`.
- [ ] `files/playwright/test-login-ldap-biber.js`: LDAP login path, gated on `skipUnlessServiceEnabled('ldap')`.
- [ ] `files/playwright/test-login-native-administrator.js`: break-glass local admin login, gated to **ldap-disabled** (Semaphore forces LDAP-only form auth when ldap is enabled); the skip carries a reason naming `LDAP_SERVICE_ENABLED`.
- [ ] `templates/playwright.env.j2` exposes every env var the specs read; service flags carry `# nocheck` rationale where applicable.
- [ ] `make compose-playwright role=web-app-semaphore` exits `0` with no stub tests and a clean logged-out final state per the contract.
### Health & quality
- [ ] The compose stack reaches a steady running state across all three variants.
- [ ] `make test` is green tree-wide (role-meta, services-contract, and playwright-parity lints pass).
### Documentation
- [ ] `roles/web-app-semaphore/README.md` documents image + bump policy, the chosen DB backend, OIDC + LDAP wiring, the variant matrix, the admin-bootstrap path, and the runner topology.
- [ ] This requirement file is cross-linked from the implementing PR (per [requirements.md#cross-linking](../contributing/requirements.md#cross-linking)).
## Validation Apps
```bash
INFINITO_APPS="web-app-semaphore" \
make deploy-fresh-purged-apps INFINITO_FULL_CYCLE=true
```
Smoke after deploy:
1. Visit `https://semaphore.{{ DOMAIN_PRIMARY }}/` — Semaphore login renders, no setup wizard.
2. Click "Sign in with Keycloak" — Keycloak chain completes, lands on the Semaphore dashboard.
3. (LDAP variant) Log in with an LDAP user — bind succeeds, lands authenticated.
4. Create a project / inspect the task templates UI — surface is interactive, not just reachable.
## Prerequisites
Before any implementation work, the agent MUST read [AGENTS.md](../../AGENTS.md) and follow it, then [Role Loop](../agents/action/iteration/compose.md) and the [Playwright spec contract](../contributing/artefact/files/role/playwright.specs.js.md).
## Implementation Strategy
Execute autonomously; open a clarification only when a decision is genuinely ambiguous and blocking.
1. Resolve the [Open Questions](#open-questions-pending-operator-decision) into [Confirmed Decisions](#confirmed-decisions).
2. Scaffold the role using [`roles/web-app-openproject/`](../../roles/web-app-openproject/) as the structural template (Postgres consumer, LDAP vars, OIDC, three personas).
3. Template `compose.yml.j2` + `env.j2` with the `SEMAPHORE_*` matrix (DB, admin bootstrap, secrets, `SEMAPHORE_WEB_ROOT`, conditional `SEMAPHORE_OIDC_PROVIDERS` and `SEMAPHORE_LDAP_*`).
4. Add Keycloak client auto-provisioning for the Semaphore consumer.
5. Add the Playwright specs (guest, OIDC biber+admin, LDAP biber, native admin).
6. Iterate `make test` until green, then run the Validation deploys across all three variants.
## Commit Policy
- No git commit until every Acceptance Criterion is checked off (`- [x]`).
- A single commit (or a tight related sequence) lands the whole role addition.
- When all ACs are met and the variants deploy cleanly, instruct the operator to run `git-sign-push` outside the sandbox (per [AGENTS.md](../../AGENTS.md)). The agent MUST NOT push.
## Context
- Upstream repo: <https://github.com/semaphoreui/semaphore>
- Upstream docs: <https://docs.semaphoreui.com/>
- OIDC / Keycloak: <https://docs.semaphoreui.com/administration-guide/openid/keycloak/>
- LDAP: <https://github.com/semaphoreui/semaphore/discussions/2106>
- Closest in-repo layout analogue: [`roles/web-app-openproject/`](../../roles/web-app-openproject/)
- Native-OIDC flavor precedent: [024 ERPNext](024-web-app-erpnext.md)
- Requirements format: [docs/contributing/requirements.md](../contributing/requirements.md)
- Playwright contract: [playwright.specs.js.md](../contributing/artefact/files/role/playwright.specs.js.md)
</content>
</invoke>