From 935b3e9f4ccdb71c65095757391d3939a58a83b3 Mon Sep 17 00:00:00 2001 From: Eric Meehan Date: Fri, 23 Oct 2026 18:15:49 -0400 Subject: [PATCH] Initial commit --- .dockerignore | 4 + .editorconfig | 6 ++ .gitignore | 4 + README.md | 194 +++++++++++++++++++++++++++++++++++++++ defaults/main.yml | 65 +++++++++++++ files/logo.svg | 24 +++++ files/styles.css | 105 +++++++++++++++++++++ meta/main.yml | 7 ++ playbooks/deploy-eom.yml | 82 +++++++++++++++++ requirements.yml | 2 + tasks/main.yml | 61 ++++++++++++ tasks/postgres.yml | 32 +++++++ templates/values.yml.j2 | 135 +++++++++++++++++++++++++++ tests/Dockerfile | 10 ++ tests/example-vars.yml | 48 ++++++++++ tests/render.yml | 52 +++++++++++ tests/test.yml | 6 ++ tests/validate.py | 72 +++++++++++++++ 18 files changed, 909 insertions(+) create mode 100755 .dockerignore create mode 100755 .editorconfig create mode 100755 .gitignore create mode 100755 README.md create mode 100755 defaults/main.yml create mode 100755 files/logo.svg create mode 100755 files/styles.css create mode 100755 meta/main.yml create mode 100755 playbooks/deploy-eom.yml create mode 100755 requirements.yml create mode 100755 tasks/main.yml create mode 100755 tasks/postgres.yml create mode 100755 templates/values.yml.j2 create mode 100755 tests/Dockerfile create mode 100755 tests/example-vars.yml create mode 100755 tests/render.yml create mode 100755 tests/test.yml create mode 100755 tests/validate.py diff --git a/.dockerignore b/.dockerignore new file mode 100755 index 0000000..ae7113e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.git +__pycache__ +.env* +*.vault diff --git a/.editorconfig b/.editorconfig new file mode 100755 index 0000000..a3984ec --- /dev/null +++ b/.editorconfig @@ -0,0 +1,6 @@ +root = true + +[*.{yml,yaml,j2}] +indent_style = space +indent_size = 2 +insert_final_newline = true diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..d087e85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.retry +.env +*.vault diff --git a/README.md b/README.md new file mode 100755 index 0000000..90e8c6e --- /dev/null +++ b/README.md @@ -0,0 +1,194 @@ +# ansible-role-dex + +Dedicated Kubernetes/Helm role extracted from ansible-role-eom's Dex task. +Deploys Dex with local **Catppuccin Mocha / Latte** styles and an **eom.dev** +wordmark, retaining Dex's shipped authentication and consent templates. + +## Generic defaults and eom.dev workflow + +Role defaults now use example domains/DNs, blank credentials, a 10Gi optional +database request, no implicit Matrix client and disabled eom.dev branding. +Plaintext LDAP remains the default as requested; TLS remains opt-in. + +Use [playbooks/deploy-eom.yml](playbooks/deploy-eom.yml) for the actual eom.dev +deployment. It calls this role and explicitly preserves the existing issuer, +namespace/releases, database settings/256Gi sizing, plaintext LDAP host, legacy +POSIX/group filter, `uid` subject mapping, Matrix callback, and Catppuccin logo. + +Provide these existing variables through controller inventory/group_vars or an +Ansible Vault-encrypted vars file: + +- `dex_admin_password`: existing Dex PostgreSQL password. +- `openldap_readonly_password`: existing LDAP search-account password. +- `matrix_dex_oidc_client_id` and `matrix_dex_oidc_client_secret`: existing MAS client. +- `postgresql_admin_password`: needed only if deliberately enabling DB management. +- Optional `www_dex_client_secret`: enables the website client and its separate + `www-ldap`/`entryUUID` connector, without changing legacy identities. It must + match the website's `DEX_CLIENT_SECRET`. Leave absent to preserve Matrix-only + behavior. Review and declare any other deployed clients before rollout. + +Run the playbook on the controller with its kubeconfig and this role available +in `ANSIBLE_ROLES_PATH`. Load your encrypted variables with Ansible's `--extra-vars` +file option and `--ask-vault-pass` or your existing Vault identity configuration; +do not put plaintext credentials in this playbook or pass them on command lines. +This is an Ansible playbook, not a CI provider workflow, and is not automatically +triggered. The old EOM compatibility entry now uses generic defaults unless you +explicitly pass these site settings; prefer the site playbook for migration. + +## Migration from ansible-role-eom + +1. Back up the Dex PostgreSQL database and record the installed Dex/PostgreSQL + Helm release versions and current values in a secure location. Values contain + credentials; do not paste them into tickets, chat or unencrypted git files. +2. Install this role under `ansible-role-dex` in your controller's role search + path. Install [requirements.yml](requirements.yml) and the Kubernetes Python + client; Helm 3 and an authorized kubeconfig are required. Tested with Ansible + Core 2.18; role metadata minimum is 2.15. +3. Use the site workflow above or invoke `ansible-role-dex` with explicit site + variables in the existing playbook. The old + `ansible-role-eom/tasks/dex.yaml` is now a thin `include_role` compatibility + entry point. Use one entry point, not both. No global eom dependency was + added: its current main task only deploys Monero and remains unchanged. +4. Keep namespace `dex`, release `dex`, issuer **https://dex.eom.dev/** (including + trailing slash), database host `postgresql`, user/database `dex` and existing + credentials. A role extraction must not change OIDC issuer or existing client + secrets/redirect URIs, connector IDs or user-ID mappings. +5. Review the pinned Dex chart `0.26.0` (app `v2.46.0`) against your installed + release **before applying**. Override `dex_chart_version` and optionally + `dex_image_tag` if staging a different supported release. This role does not + discover/guess the installed version or perform a live migration by itself. +6. PostgreSQL management defaults to **false**: the existing release and PVC are + left untouched. Do not uninstall the old release or delete its volumes. + The extracted config corrects the old `databbase` typo to `database: dex`. + +The site playbook maps legacy inventory variables explicitly: + +| Old inventory variable | New role variable | +| --- | --- | +| `dex_admin_password` | `dex_postgres_password` (DB password, not a Dex UI admin) | +| `postgresql_admin_password` | `dex_postgres_admin_password` | +| `openldap_readonly_password` | `dex_ldap_bind_password` | +| `matrix_dex_oidc_client_id` / `matrix_dex_oidc_client_secret` | Existing Matrix Auth Service client | + +The site playbook enables the existing Matrix client. Its original +redirect URI is explicit in `dex_matrix_redirect_uris`; override to match your +actual MAS configuration. Other clients previously added directly to Helm must +be declared in `dex_static_clients` before running this role. Helm values are +reset to this role's complete values: undeclared manual Helm customizations are +not implicitly preserved. The role's authoritative values must be reviewed. + +## Main variables + +See [defaults/main.yml](defaults/main.yml) for all options. + +| Variable | Default / behavior | +| --- | --- | +| `dex_namespace`, `dex_release_name` | `dex`, `dex` | +| `dex_chart_version` | `0.26.0` (explicit, reviewed release) | +| `dex_issuer`, `dex_hostname` | `https://{{ dex_hostname }}/`, `dex.example.com` | +| `dex_ingress_class`, `dex_cluster_issuer` | `nginx`, `letsencrypt-prod` | +| `dex_theme_enabled`, `dex_brand_name` | false, `Dex` (site playbook enables eom.dev theme) | +| `dex_static_clients` | Additional Dex static client objects; default empty | +| `dex_additional_connectors` | Additional complete connector objects; default empty | +| `dex_env_secret_name` | Optional existing environment Secret in Dex namespace | +| `dex_ca_secret_name` | Optional existing CA Secret mounted at `/etc/dex-ca` | +| `dex_postgres_manage` | false; reuse existing DB without changing its release | +| `dex_postgres_ssl_mode` | `disable`, preserving internal DB behavior | +| `dex_image_pull_secrets` | Kubernetes imagePullSecrets list | +| `dex_extra_volumes`, `dex_extra_volume_mounts` | Optional additional mounts | + +### LDAP: plaintext is the default, TLS is opt-in + +As requested, default LDAP behavior remains `insecureNoSSL: true` against +`ldap.example.com:389` (the site playbook uses +`openldap.openldap.svc.cluster.local`). This exposes both service-bind and user +passwords to network observers. Restrict it to the trusted internal network with +your cluster's policies; no public LDAP exposure is created by this role. + +The generic connector uses `inetOrgPerson` and DN-based group matching. The site +playbook retains ID `ldap`, `idAttr: uid`, its POSIX/group filter, +and `userAttr: cn` group matching. For `uniqueMember` values containing full DNs, +set `dex_ldap_group_user_attribute: DN` deliberately after testing. Changing +connector ID or idAttr changes OIDC subjects and can detach existing accounts. + +For **StartTLS**, explicitly set `dex_ldap_insecure_no_ssl: false`, +`dex_ldap_start_tls: true`, and a host/port supporting StartTLS. For **LDAPS**, +set both flags false and use the server's TLS hostname with port 636. Certificate +verification remains enabled; no `insecureSkipVerify` bypass is offered. For a +private CA set `dex_ca_secret_name` and `dex_ldap_root_ca` to +`/etc/dex-ca/`. Verify certificate hostname, validity, SHA-2 signature, +strong keys and CA trust independently. Additional connectors have their own +transport settings and must be configured separately. + +### Credentials and clients + +Supply database and LDAP passwords from Vault/external lookups or the supported +legacy secret variables. Helm deployment and validation tasks are `no_log` and +diff-suppressed. Dex configuration is stored by the official chart in a Kubernetes +**Secret**, never in the theme ConfigMap. Helm stores release values, including +inline credentials, in its release Secrets: protect these with RBAC, encryption +at rest and encrypted backups. This role does not generate or rotate passwords. + +Client `secretEnv` can refer to a key in `dex_env_secret_name`. LDAP connectors +support `$ENV_NAME` references, such as `bindPW: $DEX_LDAP_BIND_PASSWORD`. The +database password remains a protected Helm/Dex config value; it is not assumed +to support the same connector substitution. Never commit actual secrets. + +[tests/example-vars.yml](tests/example-vars.yml) shows a www client and separate +`www-ldap` connector using `entryUUID`, `inetOrgPerson` and DN group matching. +This avoids breaking legacy subjects while supporting new free website users. +The Secret's `WWW_DEX_CLIENT_SECRET` must match the website's `DEX_CLIENT_SECRET`; +set the website's `DEX_CONNECTOR_ID=www-ldap` and exact matching issuer. No new +client or connector is silently enabled by default. Verify that this connector's +search base includes the website account OU before enabling public registration. + +### Theme + +[files/styles.css](files/styles.css) implements responsive Mocha dark mode and +Latte light mode using `prefers-color-scheme`, visible focus rings, local fonts, +inputs, errors, connector buttons and consent panels. [files/logo.svg](files/logo.svg) +extends the website's orbit mark into an eom.dev wordmark. + +Only `themes/light/styles.css` and `themes/light/logo.svg` are mounted read-only +under the official image's `/srv/dex/web`; frontend selects that directory and +`theme/logo.svg`. All other version-matched templates, icons and static assets +come from the installed Dex image. No forms, OAuth parameters, redirects or +approval/security logic are replaced. This deliberately retains the shipped +favicon; logo/theme are local, with no third-party fonts or scripts. + +A checksum of public theme assets triggers a rollout when they change (subPath +mounts do not hot-reload). Setting `dex_theme_enabled: false` restores Dex's +default frontend; the unused ConfigMap is retained. Existing environment Secret +or CA Secret rotations require a Dex rollout; the role does not expose or hash +their contents. Inspect log privacy at any upstream proxies too: ingress access +logs are disabled to avoid recording authorization codes and related URLs. + +### Optional PostgreSQL management + +For a new install or an explicitly reviewed DB upgrade, set +`dex_postgres_manage: true` and **pin `dex_postgres_chart_version`** to the intended +Bitnami release. Defaults retain release `postgresql`, 256Gi and `volumeName: data` +from the old task. Confirm existing PV/PVC binding, storage size, chart/image +availability and schema compatibility first; do not use this to switch major +database versions without a backup/restore plan. Changing Helm password values +does not automatically rotate an existing database password. Managed DB transport +is unchanged; configure external DB TLS/mesh separately if required. + +## Validation + +[tests/Dockerfile](tests/Dockerfile) installs the declared collection and Helm, +checks Ansible syntax, renders actual values and runs the pinned chart through +Helm and strict Kubernetes schemas. Fixtures verify legacy plaintext/subjects, +Matrix client, additional environment client, opt-in TLS, theme mounts, protected +Dex config and disabled/minimal variants. No cluster is contacted by these tests. + +The theme was also exercised with the official Dex v2.46.0 image using isolated +SQLite/mock authentication. Verify real LDAP, client redirects and database +access in staging before rollout. Chart upgrades should repeat that image/theme +compatibility test. No production deployments or database changes are performed +as part of this extraction. + +The editor's scanner flags high-severity findings in the validation runner's +upstream Python base image. That image is test-only and is not deployed by this +role. Review/update approved CI base-image digests with your image scanner; +these findings are not represented as resolved by the functional tests. diff --git a/defaults/main.yml b/defaults/main.yml new file mode 100755 index 0000000..1df1d8a --- /dev/null +++ b/defaults/main.yml @@ -0,0 +1,65 @@ +--- +dex_namespace: dex +dex_release_name: dex +dex_chart_version: "0.26.0" +dex_issuer: "https://{{ dex_hostname }}/" +dex_hostname: dex.example.com +dex_ingress_class: nginx +dex_cluster_issuer: letsencrypt-prod +dex_tls_secret_name: dex +dex_wait_timeout: 600s +dex_image_tag: "" # Empty uses the pinned chart's application version. +dex_image_pull_secrets: [] +dex_resources: + requests: {cpu: 100m, memory: 128Mi} + limits: {cpu: "1", memory: 512Mi} + +# Reuse the existing postgresql release/PVC. Never upgrade it implicitly. +dex_postgres_manage: false +dex_postgres_release_name: postgresql +dex_postgres_chart_version: "" # Required if explicitly managing Bitnami PostgreSQL. +dex_postgres_storage_size: 10Gi +dex_postgres_host: postgresql +dex_postgres_port: 5432 +dex_postgres_database: dex +dex_postgres_user: dex +dex_postgres_password: "" +dex_postgres_admin_password: "" +dex_postgres_ssl_mode: disable + +# Preserve legacy internal plaintext LDAP behavior. TLS is explicitly opt-in. +# Plaintext exposes bind/user passwords; restrict this traffic to trusted networks. +dex_ldap_host: ldap.example.com:389 +dex_ldap_insecure_no_ssl: true +dex_ldap_start_tls: false +dex_ldap_bind_dn: cn=readonly,dc=example,dc=com +dex_ldap_bind_password: "" +dex_ldap_root_ca: "" +dex_ca_secret_name: "" +dex_ldap_connector_id: ldap +dex_ldap_connector_name: Account +dex_ldap_user_base_dn: ou=people,dc=example,dc=com +dex_ldap_user_filter: "(objectClass=inetOrgPerson)" +dex_ldap_id_attribute: uid +dex_ldap_name_attribute: cn +dex_ldap_group_base_dn: ou=groups,dc=example,dc=com +# groupOfUniqueNames stores full user DNs in uniqueMember. +dex_ldap_group_user_attribute: DN +dex_ldap_group_filter: "(objectClass=groupOfUniqueNames)" + +# Optional legacy Matrix convenience client. Configure its ID/secret explicitly. +dex_matrix_client_enabled: false +dex_matrix_redirect_uris: [] +# Full objects (id, name, secret or secretEnv, redirectURIs). Use Vault for secrets. +dex_static_clients: [] +# Additional connectors let www use entryUUID without changing legacy subjects. +dex_additional_connectors: [] +# Optional environment Secret for secretEnv and $ENV connector credentials. +dex_env_secret_name: "" +dex_extra_volumes: [] +dex_extra_volume_mounts: [] + +# eom.dev artwork is opt-in via the deployment playbook. +dex_theme_enabled: false +dex_brand_name: Dex +dex_theme_configmap_name: dex-catppuccin diff --git a/files/logo.svg b/files/logo.svg new file mode 100755 index 0000000..261ae49 --- /dev/null +++ b/files/logo.svg @@ -0,0 +1,24 @@ + + eom.dev — One account. Your community. + + + + + + + + + + + eom.dev + One account. Your community. + diff --git a/files/styles.css b/files/styles.css new file mode 100755 index 0000000..3471cf7 --- /dev/null +++ b/files/styles.css @@ -0,0 +1,105 @@ +/* Catppuccin Mocha / Latte. Local assets only; Dex forms/templates unchanged. */ +:root { + color-scheme: dark; + --base: #1e1e2e; + --mantle: #181825; + --surface: #313244; + --border: #585b70; + --text: #cdd6f4; + --muted: #a6adc8; + --accent: #cba6f7; + --accent-hover: #b4befe; + --error: #f38ba8; + --focus: #fab387; +} +@media (prefers-color-scheme: light) { + :root { + color-scheme: light; + --base: #eff1f5; + --mantle: #e6e9ef; + --surface: #fff; + --border: #9ca0b0; + --text: #4c4f69; + --muted: #5c5f77; + --accent: #8839ef; + --accent-hover: #7287fd; + --error: #d20f39; + --focus: #fe640b; + } +} +* { box-sizing: border-box; } +.theme-body { + margin: 0; + min-height: 100vh; + background: var(--base); + color: var(--text); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + line-height: 1.6; +} +.theme-navbar { + background: var(--mantle); + border-bottom: 1px solid var(--border); + padding: 1.4rem; + text-align: center; +} +.theme-navbar__logo-wrap { display: inline-block; max-width: 100%; } +.theme-navbar__logo { display: block; width: 240px; height: 72px; max-width: 100%; } +.dex-container { + color: var(--text); + width: min(500px, calc(100% - 2rem)); + min-width: 0; + margin: clamp(2rem, 8vh, 5rem) auto; +} +.theme-panel { + padding: clamp(1.4rem, 5vw, 2.6rem); + background: var(--mantle); + border: 1px solid var(--border); + border-radius: 1.25rem; + box-shadow: 0 12px 40px rgb(0 0 0 / 10%); +} +.theme-heading { font-size: 1.6rem; font-weight: 750; line-height: 1.25; margin: 0 0 1.2rem; } +.theme-form-row { display: block; margin: 1.1rem auto; } +.theme-form-label { display: block; text-align: left; width: 100%; max-width: 320px; margin: 0 auto .4rem; font-size: .95rem; font-weight: 600; } +.theme-form-input { + display: block; + width: 100%; + max-width: 320px; + min-height: 48px; + margin: auto; + padding: .75rem 1rem; + background: var(--base); + color: var(--text); + border: 1px solid var(--border); + border-radius: .65rem; + font: inherit; +} +.theme-form-input::placeholder { color: var(--muted); } +.dex-btn { border-radius: .65rem; box-shadow: none; font: inherit; max-width: 100%; } +.theme-btn--primary, .theme-btn--success, .theme-btn-provider { + background: var(--accent); + color: var(--mantle); + border: 1px solid transparent; + min-height: 48px; + min-width: 0; + width: 100%; + max-width: 320px; + padding: .5rem 1rem; + font-weight: 700; +} +.theme-btn-provider { background: var(--surface); color: var(--text); border-color: var(--border); } +.theme-btn--primary:hover, .theme-btn--success:hover { background: var(--accent-hover); } +.theme-btn-provider:hover { border-color: var(--accent); } +.dex-btn-icon { border-radius: .3rem; margin: 0 .5rem 0 0; } +.dex-btn-text { line-height: 2rem; padding: .2rem; } +a { color: var(--accent); text-underline-offset: .2em; } +a:hover { color: var(--accent-hover); } +.theme-link-back { margin-top: 1rem; } +.dex-subtle-text, .dex-separator, .dex-list { color: var(--muted); } +.dex-error-box { background: var(--surface); color: var(--error); border: 1px solid var(--error); border-radius: .6rem; padding: .7rem; max-width: 100%; } +.theme-form-input:focus-visible, .dex-btn:focus-visible, a:focus-visible { + outline: 3px solid var(--focus); + outline-offset: 3px; +} +@media (prefers-color-scheme: light) { + .theme-btn--primary, .theme-btn--success { color: #fff; } +} diff --git a/meta/main.yml b/meta/main.yml new file mode 100755 index 0000000..12b2c98 --- /dev/null +++ b/meta/main.yml @@ -0,0 +1,7 @@ +--- +galaxy_info: + author: Eric Meehan + description: Dex Helm deployment with Catppuccin and eom.dev branding + license: MIT-0 + min_ansible_version: "2.15" +dependencies: [] diff --git a/playbooks/deploy-eom.yml b/playbooks/deploy-eom.yml new file mode 100755 index 0000000..3e68183 --- /dev/null +++ b/playbooks/deploy-eom.yml @@ -0,0 +1,82 @@ +--- +# Run on the controller with its existing kubeconfig; no SSH target is required. +# Supply credentials via inventory/group_vars or a Vault-encrypted vars file. +- name: Deploy eom.dev Dex with Catppuccin branding + hosts: localhost + connection: local + gather_facts: false + vars: + dex_namespace: dex + dex_release_name: dex + dex_chart_version: "0.26.0" + dex_issuer: https://dex.eom.dev/ + dex_hostname: dex.eom.dev + dex_ingress_class: nginx + dex_cluster_issuer: ca-issuer + dex_tls_secret_name: dex + dex_theme_enabled: true + dex_brand_name: eom.dev + dex_theme_configmap_name: dex-catppuccin + + # Preserve the existing DB release and data; do not upgrade it by default. + dex_postgres_manage: false + dex_postgres_release_name: postgresql + dex_postgres_host: postgresql + dex_postgres_port: 5432 + dex_postgres_database: dex + dex_postgres_user: dex + dex_postgres_storage_size: 256Gi + dex_postgres_ssl_mode: disable + dex_postgres_password: "{{ dex_admin_password }}" + dex_postgres_admin_password: "{{ postgresql_admin_password | default('') }}" + + # Explicitly preserve internal plaintext LDAP and legacy OIDC subjects. + dex_ldap_host: openldap.openldap.svc.cluster.local + dex_ldap_insecure_no_ssl: true + dex_ldap_start_tls: false + dex_ldap_bind_dn: cn=readonly,dc=eom,dc=dev + dex_ldap_bind_password: "{{ openldap_readonly_password }}" + dex_ldap_connector_id: ldap + dex_ldap_connector_name: eom.dev account + dex_ldap_user_base_dn: dc=eom,dc=dev + dex_ldap_user_filter: "(&(objectClass=posixAccount)(memberOf=cn=Dex Users,ou=Dex,ou=Services,dc=eom,dc=dev))" + dex_ldap_id_attribute: uid + dex_ldap_name_attribute: givenName + dex_ldap_group_base_dn: dc=eom,dc=dev + dex_ldap_group_user_attribute: cn + dex_ldap_group_filter: "(objectClass=groupOfUniqueNames)" + + dex_matrix_client_enabled: true + dex_matrix_redirect_uris: + - https://mas.eom.dev/upstream/callback/01K96AQEZKKABW34PY3R6BVNJ4 + + # Optional website integration. Set www_dex_client_secret in Vault to enable; + # it must equal the membership application's DEX_CLIENT_SECRET. + dex_static_clients: "{{ [{'id': 'www-membership', 'name': 'eom.dev Membership', 'secret': www_dex_client_secret, 'redirectURIs': ['https://www.eom.dev/members/oidc/callback/']}] if (www_dex_client_secret | default('') | length > 0) else [] }}" + dex_additional_connectors: "{{ [_dex_eom_www_connector] if (www_dex_client_secret | default('') | length > 0) else [] }}" + _dex_eom_www_connector: + type: ldap + id: www-ldap + name: eom.dev Membership + config: + host: openldap.openldap.svc.cluster.local + insecureNoSSL: true + bindDN: cn=readonly,dc=eom,dc=dev + bindPW: "{{ openldap_readonly_password }}" + userSearch: + baseDN: ou=people,dc=eom,dc=dev + filter: "(objectClass=inetOrgPerson)" + username: uid + idAttr: entryUUID + emailAttr: mail + nameAttr: cn + preferredUsernameAttr: uid + groupSearch: + baseDN: ou=groups,dc=eom,dc=dev + filter: "(objectClass=groupOfUniqueNames)" + userMatchers: + - userAttr: DN + groupAttr: uniqueMember + nameAttr: cn + roles: + - ansible-role-dex diff --git a/requirements.yml b/requirements.yml new file mode 100755 index 0000000..253c309 --- /dev/null +++ b/requirements.yml @@ -0,0 +1,2 @@ +--- +collections: [{name: kubernetes.core, version: ">=3.0.0,<7.0.0"}] diff --git a/tasks/main.yml b/tasks/main.yml new file mode 100755 index 0000000..c786aa4 --- /dev/null +++ b/tasks/main.yml @@ -0,0 +1,61 @@ +--- +- name: Validate Dex deployment configuration + ansible.builtin.assert: + that: + - dex_issuer is match('^https://[^/@]+') + - dex_hostname is match('^[a-z0-9][a-z0-9.-]*[a-z0-9]$') + - dex_postgres_password | length > 0 + - dex_ldap_bind_password | length > 0 + - not (dex_ldap_insecure_no_ssl | bool and dex_ldap_start_tls | bool) + - not dex_postgres_manage or dex_postgres_chart_version | length > 0 + - not dex_postgres_manage or dex_postgres_admin_password | length > 0 + - not dex_matrix_client_enabled or matrix_dex_oidc_client_secret | default('') | length > 0 + fail_msg: Configure database/LDAP credentials, client secrets and explicit database chart version if managing PostgreSQL. + no_log: true + +- name: Ensure Dex namespace + kubernetes.core.k8s: + state: present + definition: + apiVersion: v1 + kind: Namespace + metadata: + name: "{{ dex_namespace }}" + +- name: Register Dex Helm repository + kubernetes.core.helm_repository: + name: dex + repo_url: https://charts.dexidp.io + +- name: Manage PostgreSQL only when explicitly requested + ansible.builtin.include_tasks: postgres.yml + when: dex_postgres_manage | bool + +- name: Deploy Catppuccin theme assets + kubernetes.core.k8s: + state: present + definition: + apiVersion: v1 + kind: ConfigMap + metadata: + name: "{{ dex_theme_configmap_name }}" + namespace: "{{ dex_namespace }}" + data: + styles.css: "{{ lookup('ansible.builtin.file', role_path ~ '/files/styles.css') }}" + logo.svg: "{{ lookup('ansible.builtin.file', role_path ~ '/files/logo.svg') }}" + when: dex_theme_enabled | bool + +- name: Deploy Dex using its existing Helm release + kubernetes.core.helm: + name: "{{ dex_release_name }}" + chart_ref: dex/dex + chart_version: "{{ dex_chart_version }}" + release_namespace: "{{ dex_namespace }}" + create_namespace: true + update_repo_cache: true + reset_values: true + wait: true + timeout: "{{ dex_wait_timeout }}" + values: "{{ lookup('ansible.builtin.template', 'values.yml.j2') | from_yaml }}" + no_log: true + diff: false diff --git a/tasks/postgres.yml b/tasks/postgres.yml new file mode 100755 index 0000000..34786d5 --- /dev/null +++ b/tasks/postgres.yml @@ -0,0 +1,32 @@ +--- +- name: Register Bitnami repository for optional database management + kubernetes.core.helm_repository: + name: bitnami + repo_url: https://charts.bitnami.com/bitnami + +- name: Deploy explicitly versioned existing PostgreSQL release + kubernetes.core.helm: + name: "{{ dex_postgres_release_name }}" + chart_ref: bitnami/postgresql + chart_version: "{{ dex_postgres_chart_version }}" + release_namespace: "{{ dex_namespace }}" + create_namespace: true + wait: true + timeout: "{{ dex_wait_timeout }}" + values: + auth: + enablePostgresUser: true + postgresPassword: "{{ dex_postgres_admin_password }}" + username: "{{ dex_postgres_user }}" + password: "{{ dex_postgres_password }}" + database: "{{ dex_postgres_database }}" + primary: + name: primary + initdb: + scripts: {} + persistence: + enabled: true + volumeName: data + size: "{{ dex_postgres_storage_size }}" + no_log: true + diff: false diff --git a/templates/values.yml.j2 b/templates/values.yml.j2 new file mode 100755 index 0000000..cad2ff1 --- /dev/null +++ b/templates/values.yml.j2 @@ -0,0 +1,135 @@ +replicaCount: 1 +image: + tag: {{ dex_image_tag | to_json }} +imagePullSecrets: {{ dex_image_pull_secrets | to_json }} +rbac: + create: false +resources: {{ dex_resources | to_json }} +securityContext: + runAsNonRoot: true + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: [ALL] +podAnnotations: + theme/revision: {{ (lookup('ansible.builtin.file', role_path ~ '/files/styles.css') ~ lookup('ansible.builtin.file', role_path ~ '/files/logo.svg') ~ (dex_theme_enabled | string) ~ dex_env_secret_name ~ dex_ca_secret_name) | hash('sha256') | to_json }} +{% if dex_env_secret_name %} +envFrom: + - secretRef: + name: {{ dex_env_secret_name | to_json }} +{% endif %} +config: + issuer: {{ dex_issuer | to_json }} + logger: + level: info + format: json +{% if dex_theme_enabled %} + frontend: + issuer: {{ dex_brand_name | to_json }} + dir: /srv/dex/web + theme: light + logoURL: theme/logo.svg +{% endif %} + storage: + type: postgres + config: + host: {{ dex_postgres_host | to_json }} + port: {{ dex_postgres_port }} + database: {{ dex_postgres_database | to_json }} + user: {{ dex_postgres_user | to_json }} + password: {{ dex_postgres_password | to_json }} + ssl: + mode: {{ dex_postgres_ssl_mode | to_json }} + staticClients:{% if not dex_matrix_client_enabled and not dex_static_clients %} []{% endif %} + +{% if dex_matrix_client_enabled %} + - id: {{ matrix_dex_oidc_client_id | to_json }} + name: Matrix Auth Service + secret: {{ matrix_dex_oidc_client_secret | to_json }} + redirectURIs: {{ dex_matrix_redirect_uris | to_json }} +{% endif %} +{% for client in dex_static_clients %} + - {{ client | to_json }} +{% endfor %} + connectors: + - type: ldap + id: {{ dex_ldap_connector_id | to_json }} + name: {{ dex_ldap_connector_name | to_json }} + config: + host: {{ dex_ldap_host | to_json }} + insecureNoSSL: {{ dex_ldap_insecure_no_ssl | bool | to_json }} + startTLS: {{ dex_ldap_start_tls | bool | to_json }} + insecureSkipVerify: false +{% if dex_ldap_root_ca %} + rootCA: {{ dex_ldap_root_ca | to_json }} +{% endif %} + bindDN: {{ dex_ldap_bind_dn | to_json }} + bindPW: {{ dex_ldap_bind_password | to_json }} + userSearch: + baseDN: {{ dex_ldap_user_base_dn | to_json }} + filter: {{ dex_ldap_user_filter | to_json }} + username: uid + idAttr: {{ dex_ldap_id_attribute | to_json }} + emailAttr: mail + nameAttr: {{ dex_ldap_name_attribute | to_json }} + preferredUsernameAttr: uid + groupSearch: + baseDN: {{ dex_ldap_group_base_dn | to_json }} + filter: {{ dex_ldap_group_filter | to_json }} + userMatchers: + - userAttr: {{ dex_ldap_group_user_attribute | to_json }} + groupAttr: uniqueMember + nameAttr: cn +{% for connector in dex_additional_connectors %} + - {{ connector | to_json }} +{% endfor %} +volumes:{% if not dex_theme_enabled and not dex_ca_secret_name and not dex_extra_volumes %} []{% endif %} + +{% if dex_theme_enabled %} + - name: theme + configMap: + name: {{ dex_theme_configmap_name }} +{% endif %} +{% if dex_ca_secret_name %} + - name: ca + secret: + secretName: {{ dex_ca_secret_name }} +{% endif %} +{% for volume in dex_extra_volumes %} + - {{ volume | to_json }} +{% endfor %} +volumeMounts:{% if not dex_theme_enabled and not dex_ca_secret_name and not dex_extra_volume_mounts %} []{% endif %} + +{% if dex_theme_enabled %} + - name: theme + mountPath: /srv/dex/web/themes/light/styles.css + subPath: styles.css + readOnly: true + - name: theme + mountPath: /srv/dex/web/themes/light/logo.svg + subPath: logo.svg + readOnly: true +{% endif %} +{% if dex_ca_secret_name %} + - name: ca + mountPath: /etc/dex-ca + readOnly: true +{% endif %} +{% for mount in dex_extra_volume_mounts %} + - {{ mount | to_json }} +{% endfor %} +ingress: + enabled: true + className: {{ dex_ingress_class | to_json }} + annotations: + cert-manager.io/cluster-issuer: {{ dex_cluster_issuer | to_json }} + nginx.ingress.kubernetes.io/ssl-redirect: "true" + nginx.ingress.kubernetes.io/enable-access-log: "false" + hosts: + - host: {{ dex_hostname }} + paths: + - path: / + pathType: Prefix + tls: + - hosts: [{{ dex_hostname }}] + secretName: {{ dex_tls_secret_name }} diff --git a/tests/Dockerfile b/tests/Dockerfile new file mode 100755 index 0000000..f9350cd --- /dev/null +++ b/tests/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.13-slim-trixie +RUN pip install --no-cache-dir 'ansible-core>=2.18,<2.19' 'kubernetes>=28,<34' PyYAML 'kubernetes-validate>=1.31,<1.35' +ADD https://get.helm.sh/helm-v3.17.3-linux-amd64.tar.gz /tmp/helm.tar.gz +RUN echo 'ee88b3c851ae6466a3de507f7be73fe94d54cbf2987cbaa3d1a3832ea331f2cd /tmp/helm.tar.gz' | sha256sum -c - && tar -xzf /tmp/helm.tar.gz -C /tmp && mv /tmp/linux-amd64/helm /usr/local/bin/helm && rm -rf /tmp/linux-amd64 /tmp/helm.tar.gz +COPY requirements.yml /tmp/requirements.yml +RUN ansible-galaxy collection install -r /tmp/requirements.yml +WORKDIR /roles/ansible-role-dex +COPY . ./ +ENV ANSIBLE_ROLES_PATH=/roles +CMD ["sh", "-ec", "ansible-playbook -i localhost, tests/test.yml --syntax-check; ansible-playbook -i localhost, playbooks/deploy-eom.yml --syntax-check; ansible-playbook -i localhost, tests/render.yml; helm repo add dex https://charts.dexidp.io; helm template dex dex/dex --version 0.26.0 --namespace dex -f /tmp/dex-values.yml > /tmp/dex-manifests.yml; python tests/validate.py"] diff --git a/tests/example-vars.yml b/tests/example-vars.yml new file mode 100755 index 0000000..b6eea6b --- /dev/null +++ b/tests/example-vars.yml @@ -0,0 +1,48 @@ +--- +# Passwords supplied in a Vault-encrypted inventory, never in this example. +dex_issuer: https://dex.eom.dev/ +dex_hostname: dex.eom.dev +dex_postgres_manage: false +dex_postgres_host: postgresql +dex_postgres_database: dex +dex_postgres_user: dex +# Legacy credential names are still supported by defaults: +# dex_admin_password, openldap_readonly_password, matrix_dex_oidc_client_secret. + +# Existing Secret in the Dex namespace. WWW_DEX_CLIENT_SECRET must match the +# www membership app's DEX_CLIENT_SECRET. DEX_LDAP_BIND_PASSWORD is the search +# account password; no real values go in this public file. +dex_env_secret_name: dex-client-secrets +dex_static_clients: + - id: www-membership + name: eom.dev Membership + secretEnv: WWW_DEX_CLIENT_SECRET + redirectURIs: + - https://www.eom.dev/members/oidc/callback/ + +# Additional connector: don't alter legacy ldap/idAttr uid, which changes sub +# identifiers for existing applications. This connector admits free web accounts. +dex_additional_connectors: + - type: ldap + id: www-ldap + name: eom.dev Membership + config: + host: openldap.openldap.svc.cluster.local + insecureNoSSL: true + bindDN: cn=readonly,dc=eom,dc=dev + bindPW: $DEX_LDAP_BIND_PASSWORD + userSearch: + baseDN: ou=people,dc=eom,dc=dev + filter: "(objectClass=inetOrgPerson)" + username: uid + idAttr: entryUUID + emailAttr: mail + nameAttr: cn + preferredUsernameAttr: uid + groupSearch: + baseDN: ou=groups,dc=eom,dc=dev + filter: "(objectClass=groupOfUniqueNames)" + userMatchers: + - userAttr: DN + groupAttr: uniqueMember + nameAttr: cn diff --git a/tests/render.yml b/tests/render.yml new file mode 100755 index 0000000..7a3b3a9 --- /dev/null +++ b/tests/render.yml @@ -0,0 +1,52 @@ +--- +- name: Render Dex values without a cluster or real secrets + hosts: localhost + connection: local + gather_facts: false + vars: + role_path: /roles/ansible-role-dex + tasks: + - name: Load defaults + ansible.builtin.include_vars: ../defaults/main.yml + - name: Set fake isolated credentials + ansible.builtin.set_fact: + dex_postgres_password: fixture-not-a-real-password + dex_ldap_bind_password: fixture-not-a-real-password + dex_matrix_client_enabled: true + matrix_dex_oidc_client_id: matrix-fixture + matrix_dex_oidc_client_secret: fixture-not-a-real-client-secret + dex_theme_enabled: true + - name: Render default values + ansible.builtin.template: + src: ../templates/values.yml.j2 + dest: /tmp/dex-values.yml + mode: "0600" + - name: Set optional settings + ansible.builtin.set_fact: + dex_theme_enabled: false + dex_matrix_client_enabled: false + dex_ldap_insecure_no_ssl: false + dex_ldap_start_tls: true + dex_ldap_root_ca: /etc/dex-ca/ldap.pem + dex_ca_secret_name: ldap-ca + dex_env_secret_name: dex-client-secrets + dex_static_clients: + - id: www-membership + name: Example Membership + secretEnv: WWW_DEX_CLIENT_SECRET + redirectURIs: [https://www.example.com/members/oidc/callback/] + - name: Render optional values + ansible.builtin.template: + src: ../templates/values.yml.j2 + dest: /tmp/dex-values-optional.yml + mode: "0600" + - name: Set empty optional lists + ansible.builtin.set_fact: + dex_ca_secret_name: "" + dex_env_secret_name: "" + dex_static_clients: [] + - name: Render minimal values + ansible.builtin.template: + src: ../templates/values.yml.j2 + dest: /tmp/dex-values-minimal.yml + mode: "0600" diff --git a/tests/test.yml b/tests/test.yml new file mode 100755 index 0000000..d738a33 --- /dev/null +++ b/tests/test.yml @@ -0,0 +1,6 @@ +--- +- name: Check dedicated Dex role syntax + hosts: localhost + gather_facts: false + roles: + - ansible-role-dex diff --git a/tests/validate.py b/tests/validate.py new file mode 100755 index 0000000..df85569 --- /dev/null +++ b/tests/validate.py @@ -0,0 +1,72 @@ +"""Check real Ansible-rendered values and Helm manifests without a cluster.""" + +import base64 +from pathlib import Path + +import yaml +from kubernetes_validate import validate +from ansible.parsing.dataloader import DataLoader +from ansible.template import Templar + +# Resolve the site's actual workflow variables with fixture credentials only; +# verify both Matrix-only migration and opt-in website integration. +defaults = yaml.safe_load(Path('defaults/main.yml').read_text()) +site = yaml.safe_load(Path('playbooks/deploy-eom.yml').read_text())[0]['vars'] +for website_enabled in (False, True): + variables = { + **defaults, **site, + 'dex_admin_password': 'fixture-db', + 'openldap_readonly_password': 'fixture-ldap', + 'matrix_dex_oidc_client_id': 'matrix-fixture', + 'matrix_dex_oidc_client_secret': 'fixture-matrix', + } + if website_enabled: + variables['www_dex_client_secret'] = 'fixture-www' + resolved = Templar(loader=DataLoader(), variables=variables).template(site) + assert resolved['dex_issuer'] == 'https://dex.eom.dev/' + assert resolved['dex_postgres_password'] == 'fixture-db' + assert resolved['dex_ldap_group_user_attribute'] == 'cn' + assert resolved['dex_theme_enabled'] is True + assert resolved['dex_postgres_manage'] is False + assert bool(resolved['dex_static_clients']) == website_enabled + assert bool(resolved['dex_additional_connectors']) == website_enabled + if website_enabled: + assert resolved['dex_static_clients'][0]['secret'] == 'fixture-www' + assert resolved['dex_additional_connectors'][0]['config']['userSearch']['idAttr'] == 'entryUUID' + +values = yaml.safe_load(Path('/tmp/dex-values.yml').read_text()) +config = values['config'] +assert config['issuer'] == 'https://dex.example.com/' +assert config['storage']['config']['database'] == 'dex' +assert 'databbase' not in config['storage']['config'] +ldap = config['connectors'][0] +assert ldap['id'] == 'ldap' +assert ldap['config']['insecureNoSSL'] is True +assert ldap['config']['startTLS'] is False +assert ldap['config']['userSearch']['idAttr'] == 'uid' +assert ldap['config']['groupSearch']['userMatchers'][0]['userAttr'] == 'DN' +assert config['staticClients'][0]['id'] == 'matrix-fixture' +assert config['frontend']['logoURL'] == 'theme/logo.svg' +assert config['frontend']['dir'] == '/srv/dex/web' +assert len(values['volumeMounts']) == 2 +assert all(mount['readOnly'] for mount in values['volumeMounts']) +optional = yaml.safe_load(Path('/tmp/dex-values-optional.yml').read_text()) +assert 'frontend' not in optional['config'] +assert optional['config']['connectors'][0]['config']['startTLS'] is True +assert optional['config']['staticClients'][0]['secretEnv'] == 'WWW_DEX_CLIENT_SECRET' +assert optional['envFrom'][0]['secretRef']['name'] == 'dex-client-secrets' +minimal = yaml.safe_load(Path('/tmp/dex-values-minimal.yml').read_text()) +assert minimal['config']['staticClients'] == [] +assert minimal['volumes'] == [] and minimal['volumeMounts'] == [] +objects = [obj for obj in yaml.safe_load_all(Path('/tmp/dex-manifests.yml').read_text()) if obj] +for obj in objects: + validate(obj, desired_version='1.31.0', strict=True) +deployment = next(obj for obj in objects if obj['kind'] == 'Deployment') +assert deployment['metadata']['name'] == 'dex' +mounts = deployment['spec']['template']['spec']['containers'][0]['volumeMounts'] +assert any(mount['mountPath'].endswith('/light/styles.css') for mount in mounts) +secret = next(obj for obj in objects if obj['kind'] == 'Secret') +stored = yaml.safe_load(base64.b64decode(secret['data']['config.yaml'])) +assert stored['storage']['config']['password'] == 'fixture-not-a-real-password' +assert not any(obj['kind'] in ['PersistentVolumeClaim', 'StatefulSet'] for obj in objects) +print(f'PASS: {len(objects)} real Helm resources; legacy identity/plain LDAP preserved, optional TLS, clients, theme mounts and Secret config validated')