028 - Semaphore UI Role with OIDC SSO + LDAP¶
User Story¶
As a platform administrator of Infinito.Nexus, I want Semaphore UI 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 (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 |
|
|
Config model |
All settings available as |
|
Database |
|
|
Admin bootstrap |
|
|
Secrets |
|
|
OIDC (native) |
|
|
LDAP (native) |
|
|
Reverse proxy |
|
|
Runner |
A built-in (embedded) runner executes tasks in the main container; external |
|
Auth precedence |
When OIDC and/or LDAP are enabled, local password login is deprioritised; users authenticate via the configured provider(s). |
In-repo analogues¶
SSO flavor is
oidc(native), notoauth2-proxy— same pattern asweb-app-erpnextandweb-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(Postgres consumer,vars/ldap.yml, OIDC, three personas).Keycloak OIDC client is auto-provisioned by
web-app-keycloakfor 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 |
Standard single-host convention for a new web-app role. |
2 |
SSO flavor is |
Keeps Semaphore’s API/runner token flow and its native auth UX, and lets LDAP coexist. |
3 |
Database backend is central PostgreSQL via |
Matches the platform convention used by |
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 |
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 |
Verified against the v2.18.12 source: the login form is LDAP-only when |
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)¶
---
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 throughsys-svc-proxyto the Semaphore container andGET /returns HTTP 200 with the Semaphore SPA shell.[ ]
SEMAPHORE_WEB_ROOTis set to the canonicalhttps://URL so OIDC redirect URLs resolve correctly behind the proxy.[ ] The role’s
Content-Security-Policyresponse 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.[ ]
meta/services.ymlpinssemaphoreui/semaphoreto a concrete stable2.xsemver (no:latest).[ ]
meta/info.yml,server.yml,main.yml,secrets.yml,users.yml,rbac.yml,variants.yml,volumes.ymlexist and pass the repo’s role-meta lint.
Database (Decision #3 — central PostgreSQL)¶
[ ]
SEMAPHORE_DB_DIALECT=postgresplusSEMAPHORE_DB_HOST/PORT/USER/PASS/SEMAPHORE_DBare wired; whenservices.postgres.shared=truethe role consumes the centralsvc-db-postgres(no per-role engine), elsesys-svc-rdbmstemplates 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_EMAILseed a break-glass local admin (dedicatedbreakglasslogin + 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_ENCRYPTIONand 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-keycloakis ingroup_names, the Keycloak OIDC client for Semaphore is auto-provisioned with redirect URIhttps://semaphore.<DOMAIN_PRIMARY>/api/auth/oidc/keycloak/redirect.[ ]
SEMAPHORE_OIDC_PROVIDERSis rendered as valid JSON (keycloakprovider:display_name,provider_url=realm issuer,client_id,client_secret,redirect_url).[ ] When OIDC is enabled, the role creates the Keycloak
administratoras an external Semaphore admin at deploy time (semaphore user add --admin --external, matchingpreferred_username+ email), so the OIDC callback elevates that operator to admin (verified for theadministratorpersona). 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
/userssurface).
LDAP (V3 + V1 dual)¶
[ ] When
svc-db-openldapis ingroup_names,SEMAPHORE_LDAP_ENABLE=trueplusSEMAPHORE_LDAP_SERVER(host:port),SEMAPHORE_LDAP_BIND_DN/_BIND_PASSWORD,SEMAPHORE_LDAP_SEARCH_DN/_SEARCH_FILTER, and theSEMAPHORE_LDAP_MAPPING_*attributes are rendered from the central LDAP vars (env names per the v2.x config schema).[ ] An LDAP-bind login for
bibersucceeds and lands on an authenticated surface (covered by a dedicated Playwright scenario gated onldap).[ ] 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.ymldefines 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)¶
[ ]
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.jsandtest-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 onskipUnlessServiceEnabled('sso').[ ]
files/playwright/test-login-ldap-biber.js: LDAP login path, gated onskipUnlessServiceEnabled('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 namingLDAP_SERVICE_ENABLED.[ ]
templates/playwright.env.j2exposes every env var the specs read; service flags carry# nocheckrationale where applicable.[ ]
make compose-playwright role=web-app-semaphoreexits0with 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 testis green tree-wide (role-meta, services-contract, and playwright-parity lints pass).
Documentation¶
[ ]
roles/web-app-semaphore/README.mddocuments 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).
Validation Apps¶
INFINITO_APPS="web-app-semaphore" \
make deploy-fresh-purged-apps INFINITO_FULL_CYCLE=true
Smoke after deploy:
Visit
https://semaphore.{{ DOMAIN_PRIMARY }}/— Semaphore login renders, no setup wizard.Click “Sign in with Keycloak” — Keycloak chain completes, lands on the Semaphore dashboard.
(LDAP variant) Log in with an LDAP user — bind succeeds, lands authenticated.
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 and follow it, then Role Loop and the Playwright spec contract.
Implementation Strategy¶
Execute autonomously; open a clarification only when a decision is genuinely ambiguous and blocking.
Resolve the Open Questions into Confirmed Decisions.
Scaffold the role using
roles/web-app-openproject/as the structural template (Postgres consumer, LDAP vars, OIDC, three personas).Template
compose.yml.j2+env.j2with theSEMAPHORE_*matrix (DB, admin bootstrap, secrets,SEMAPHORE_WEB_ROOT, conditionalSEMAPHORE_OIDC_PROVIDERSandSEMAPHORE_LDAP_*).Add Keycloak client auto-provisioning for the Semaphore consumer.
Add the Playwright specs (guest, OIDC biber+admin, LDAP biber, native admin).
Iterate
make testuntil 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-pushoutside the sandbox (per 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/Native-OIDC flavor precedent: 024 ERPNext
Requirements format: docs/contributing/requirements.md
Playwright contract: playwright.specs.js.md