Compare commits

...
13 Commits
Author SHA1 Message Date
claudeandClaude Opus 5.5 f59d2b72fb www: redirect alias hostnames (e.g. apex) to canonical origin
Adds www_redirect_hostnames; renders a www-redirect ingress that 301s
to https://<www_hostname>$request_uri while leaving more specific paths
on the same host (e.g. /.well-known/matrix/*) to their own ingresses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 13:04:11 -05:00
claudeandClaude Opus 5.5 eea6389a5a www: pass listmonk newsletter settings to membership
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 20:27:43 -05:00
ClaudeandClaude Opus 5.5 d694ae5650 www: redirect /members to /members/, ensure membership LDAP groups
Service sign-up buttons link to /members, which 404'd because only
/members/ is proxied. Use an absolute https target since TLS ends at
the ingress.

Add an opt-in task that creates any missing tier/service group from
the role's config (with the placeholder member) in the OpenLDAP pod,
leaving existing groups untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 18:33:25 -05:00
ericandClaude Opus 5.5 ab6dbac3fd Membership: point tier and service groups at real LDAP DNs
Tier groups live under ou=Memberships and service groups under
ou=<Service>,ou=Services; the old DNs did not exist, so every
group sync failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 17:55:30 -05:00
eric 47adc08243 defaults 2026-10-29 14:56:18 -04:00
eric 099e8b1001 defaults 2026-10-29 14:33:26 -04:00
eric 41dfb8d3d4 defaults 2026-10-29 14:27:26 -04:00
eric a56eec0b1c Tiered LDAP groups 2026-10-29 14:24:47 -04:00
eric 4c1174df19 Tiers 2026-10-27 19:51:10 -04:00
eric b64bb5683b Level 0 2026-10-27 16:53:45 -04:00
eric 18a94f0eb0 Levels 2026-10-27 15:56:44 -04:00
eric 827ec93663 TLS 2026-10-26 20:33:41 -04:00
eric e048f579a2 idk 2026-10-26 20:33:28 -04:00
10 changed files with 367 additions and 25 deletions
Vendored Executable
+9
View File
@@ -0,0 +1,9 @@
{
"python-envs.pythonProjects": [
{
"path": ".",
"envManager": "ms-python.python:system",
"packageManager": "ms-python.python:pip"
}
]
}
+107 -2
View File
@@ -54,6 +54,7 @@ Defaults deliberately refuse incomplete membership deployments. Configure:
| --- | --- | | --- | --- |
| `www_namespace` | Namespace, default `www` | | `www_namespace` | Namespace, default `www` |
| `www_hostname` | Canonical HTTPS hostname, default **www.eom.dev** | | `www_hostname` | Canonical HTTPS hostname, default **www.eom.dev** |
| `www_redirect_hostnames` | Hostnames 301-redirected to `www_hostname`, default `[]` |
| `www_membership_enabled` | Deploy membership/proxy, default true | | `www_membership_enabled` | Deploy membership/proxy, default true |
| `www_membership_image` | Required versioned application image/digest | | `www_membership_image` | Required versioned application image/digest |
| `www_membership_secret_name` | Existing Secret, default `membership` | | `www_membership_secret_name` | Existing Secret, default `membership` |
@@ -79,6 +80,14 @@ Stripe needs `STRIPE_PORTAL_CONFIGURATION`. SMTP STARTTLS, production PostgreSQL
and HTTPS are required; production SQLite is rejected. A complete override of and HTTPS are required; production SQLite is rejected. A complete override of
`www_membership_environment` is possible but must preserve mandatory defaults. `www_membership_environment` is possible but must preserve mandatory defaults.
LDAP uses verified LDAPS/StartTLS by default. For deliberate plaintext LDAP
inside a trusted cluster, set `LDAP_URL: ldap://openldap.openldap.svc.cluster.local:389`
and `LDAP_ALLOW_INSECURE: "1"` in `www_membership_environment_overrides`, then
redeploy with a membership image containing this option. This applies to both web
and worker and sends bind/registration passwords and directory data unencrypted.
The default is `"0"`; `ldaps://` remains certificate-verified regardless of this
option. There is no automatic downgrade after a TLS failure.
Payment flags validate operator intent; **actual application availability is Payment flags validate operator intent; **actual application availability is
controlled by its environment, secrets and tiers**. Disabling Stripe validation controlled by its environment, secrets and tiers**. Disabling Stripe validation
alone does not remove its button. Set an empty tier mapping to disable all paid alone does not remove its button. Set an empty tier mapping to disable all paid
@@ -86,10 +95,49 @@ tiers. SHKeeper needs both its flag and URL/currencies plus API key in the Secre
It prepays one calendar month per invoice, without automatic renewal. It prepays one calendar month per invoice, without automatic renewal.
Defaults reuse product `prod_Sc9N5J0vNAdLCn` and the same five live Price IDs as Defaults reuse product `prod_Sc9N5J0vNAdLCn` and the same five live Price IDs as
the application. Provision dedicated `www-level-0` through `www-level-4` the application. Level 0 is the free account baseline; paid offers are levels
1–5. Provision dedicated `www-level-1` through `www-level-5`
groupOfUniqueNames groups, sentinel and ACLs separately. Override group DNs if groupOfUniqueNames groups, sentinel and ACLs separately. Override group DNs if
needed; sandbox testing requires separate test-mode prices. needed; sandbox testing requires separate test-mode prices.
### Service access groups, including free Level 0
Set `www_membership_service_groups` to a mapping of **quoted effective level**
keys to lists of pre-created LDAP group DNs. The default `{}` grants no additional
groups. For example:
```yaml
www_membership_service_groups:
"0": ["cn=community-chat,ou=membership-services,dc=example,dc=org"]
"1": ["cn=community-chat,ou=membership-services,dc=example,dc=org"]
"2":
- "cn=community-chat,ou=membership-services,dc=example,dc=org"
- "cn=game-servers,ou=membership-services,dc=example,dc=org"
```
The highest valid paid membership selects exactly that level's list. **No implicit
inheritance**: include groups again for levels 3–5 if they should also receive
them. Level 0 is selected only when no paid entitlement remains; repeat a free
service DN at every level to offer it to all members. Original tier `group_dn`
grants are unchanged and independent of these additional service groups.
The role serializes the mapping into the existing membership-tiers ConfigMap as
`service-groups.json`, sets `MEMBERSHIP_SERVICE_GROUPS_FILE`, and includes it in
the configuration revision so updates trigger workload rollout. Do not override
it using `MEMBERSHIP_SERVICE_GROUPS_JSON` in the public environment. Publish an
updated membership image before deploying this role change; no new DB migration
is needed. See [tests/example-vars.yml](tests/example-vars.yml) for all six levels.
Targets must be dedicated `groupOfUniqueNames` groups with the existing sentinel;
provision groups and narrow `uniqueMember` worker ACLs separately. Configure each
service/Dex to authorize those groups. Never map admin/staff or independently
managed groups: the worker **removes noneligible local members**, including
manually granted memberships. Keep retired groups/ACLs until revocation completes.
Historical group targets persist for safe removal after mappings change. Expiry
returns users to level 0 without waiting for Stripe/SHKeeper, but LDAP outages
and downstream cached sessions can still delay access changes. Free grants cover
local membership accounts, not untouched users elsewhere in LDAP.
### Secrets ### Secrets
Create an Opaque Secret using External Secrets, Sealed Secrets or your secret Create an Opaque Secret using External Secrets, Sealed Secrets or your secret
@@ -115,12 +163,66 @@ Do not use root/admin LDAP credentials. Restrict ACLs to the website account OU
and dedicated group attributes. Use SHKeeper's invoice API key, never wallet and dedicated group attributes. Use SHKeeper's invoice API key, never wallet
private keys or UI/payout credentials. private keys or UI/payout credentials.
`LDAP_BIND_DN` names the membership service account; `LDAP_BIND_PASSWORD` must
be its original plaintext password, **not** its stored LDAP `userPassword` hash
or an already-base64-encoded value. Supply original values in
`www_membership_secret_data`; the role base64-encodes them once for Secret `data`.
Current membership login uses verified Dex issuer/subject claims without an LDAP
connection. These service credentials remain required for account registration
and worker group synchronization. Older images logging
`LDAP service bind rejected during Dex callback` or
`Dex callback rejected kind=LDAPInvalidCredentialsResult` still perform the
retired callback lookup; upgrade the membership image to remove that dependency.
Fix service credentials separately for registration/sync, then rerun the role to
roll out web and worker. Do not use directory administrator credentials.
`LDAP_PASSWORD_SCHEME` gates new registrations only; it does not select the hash
algorithm for existing-user login.
The Dex-only login release adds migration `0004_dex_login_identity`. Keep
`DEX_ISSUER` unchanged during this upgrade so existing subjects are pinned to
their original issuer, and coordinate replacing web/worker together. Existing
subject bindings and billing data are retained. Unbound local accounts require
proof from their recent registration session or an administrator's explicit
`bind_dex_identity` mapping (`--account`, `--uid`, `--subject`); they are never
claimed by email/username matching alone. Review any retired connector bindings
before deployment. Keep Dex's existing connector/uid mapping unchanged and do not
recycle usernames. The worker pins new users' directory UUIDs separately; LDAP
outages can still delay paid group grants/revocations even though login works.
Private trust roots belong in a separate CA Secret. Set paths such as Private trust roots belong in a separate CA Secret. Set paths such as
`LDAP_CA_CERT_FILE`, `SHKEEPER_CA_CERT_FILE`, `PGSSLROOTCERT` or `LDAP_CA_CERT_FILE`, `SHKEEPER_CA_CERT_FILE`, `PGSSLROOTCERT` or
`REQUESTS_CA_BUNDLE` to `/etc/membership/ca/<secret-key>`. The read-only mount is `REQUESTS_CA_BUNDLE` to `/etc/membership/ca/<secret-key>`. The read-only mount is
shared by web, worker and migrations. Verify certificate expiry, strong keys, shared by web, worker and migrations. Verify certificate expiry, strong keys,
SHA-2 signatures and trust independently; do not disable TLS verification. SHA-2 signatures and trust independently; do not disable TLS verification.
### Self-service profiles
The profile release requires a membership image with Pillow and migration
`0005_profile_email_change`, plus the updated website templates/CSS. The role's
migration Job still runs before workloads. Deploy matching reviewed versions of
the application and website; do not reuse cached image tags.
The membership page links to an authenticated editor with friendly labels for
the requested inetOrgPerson attributes, with `displayName` labelled **Gamer tag**.
Dex's normal claims do not contain the full profile; opening the editor reads
the current LDAP entry, while ordinary sign-in remains LDAP-independent. The
service identity needs read/search access to profile attributes and operational
UUID/timestamp fields. Writes use the member's current password and **user bind**,
not broader service privileges. Configure narrow self-write ACLs, server-side
mail/uid uniqueness and RFC 4528 assertion support. SSH key edits additionally
require the `ldapPublicKey` schema and pre-added auxiliary class or an ACL allowing
only that specific self-add. This role does not create schema/ACLs automatically.
Email changes require mailbox verification plus the current password and replace
all existing mail values. Password hashing is delegated to slapd's Password Modify
policy; the application does not generate MD5-Crypt or expose stored hashes.
Review legacy hashing separately. See the application's self-service profile
documentation for the full attribute mapping and directory prerequisites.
The ingress upload limit is 2 MiB to accommodate a 1 MiB JPEG and multipart
overhead. Apache retains 256 KiB for non-profile member routes. Keep access/body
logging disabled for verification URLs, passwords and other profile data.
### Example inventory and hostname ### Example inventory and hostname
[tests/example-vars.yml](tests/example-vars.yml) contains public configuration [tests/example-vars.yml](tests/example-vars.yml) contains public configuration
@@ -138,7 +240,10 @@ app defaults to eom.dev. This role sets `MEMBERSHIP_PUBLIC_URL` to
To serve the apex instead, set `www_hostname: eom.dev` and align DNS, To serve the apex instead, set `www_hostname: eom.dev` and align DNS,
certificates, callbacks and redirects. Membership requires one canonical HTTPS certificates, callbacks and redirects. Membership requires one canonical HTTPS
origin for sessions, CSRF, email verification and OIDC. origin for sessions, CSRF, email verification and OIDC. To send apex visitors
to the canonical origin without serving it there, set
`www_redirect_hostnames: [eom.dev]`; other ingresses' more specific apex paths
(such as `/.well-known/matrix/*`) keep working.
## Upgrades and operations ## Upgrades and operations
+114 -17
View File
@@ -15,6 +15,8 @@ www_repo_url: https://git.example.com/organization/www.git
www_namespace: www www_namespace: www
www_hostname: www.example.com www_hostname: www.example.com
www_ingress_class: nginx www_ingress_class: nginx
# Extra hostnames 301-redirected to https://{{ www_hostname }} (e.g. [eom.dev]).
www_redirect_hostnames: []
www_cluster_issuer: letsencrypt-prod www_cluster_issuer: letsencrypt-prod
# Set to your published website image, including any required metrics exporter. # Set to your published website image, including any required metrics exporter.
www_httpd_image: registry.example.com/organization/www:release www_httpd_image: registry.example.com/organization/www:release
@@ -55,12 +57,54 @@ www_membership_stripe_enabled: false
www_membership_shkeeper_enabled: false www_membership_shkeeper_enabled: false
www_membership_environment_overrides: {} www_membership_environment_overrides: {}
# Explicit effective membership level -> service group DNs. No inheritance.
# Include a group at each level that should receive it; level 0 is free-only.
# Only dedicate groups whose entire membership may be managed by this service.
www_membership_service_groups:
"0":
- "cn=Dex Users,ou=Dex,ou=Services,dc=eom,dc=dev"
- "cn=Discourse Users,ou=Discourse,ou=Services,dc=eom,dc=dev"
- "cn=Gitea Users,ou=Gitea,ou=Services,dc=eom,dc=dev"
- "cn=Matrix Users,ou=Matrix,ou=Services,dc=eom,dc=dev"
"1":
- "cn=Dex Users,ou=Dex,ou=Services,dc=eom,dc=dev"
- "cn=Discourse Users,ou=Discourse,ou=Services,dc=eom,dc=dev"
- "cn=Gitea Users,ou=Gitea,ou=Services,dc=eom,dc=dev"
- "cn=Matrix Users,ou=Matrix,ou=Services,dc=eom,dc=dev"
"2":
- "cn=Dex Users,ou=Dex,ou=Services,dc=eom,dc=dev"
- "cn=Discourse Users,ou=Discourse,ou=Services,dc=eom,dc=dev"
- "cn=Gitea Users,ou=Gitea,ou=Services,dc=eom,dc=dev"
- "cn=Matrix Users,ou=Matrix,ou=Services,dc=eom,dc=dev"
"3":
- "cn=Dex Users,ou=Dex,ou=Services,dc=eom,dc=dev"
- "cn=Discourse Users,ou=Discourse,ou=Services,dc=eom,dc=dev"
- "cn=Gitea Users,ou=Gitea,ou=Services,dc=eom,dc=dev"
- "cn=Matrix Users,ou=Matrix,ou=Services,dc=eom,dc=dev"
- "cn=Mastodon Users,ou=Mastodon,ou=Services,dc=eom,dc=dev"
- "cn=PeerTube Users,ou=PeerTube,ou=Services,dc=eom,dc=dev"
"4":
- "cn=Dex Users,ou=Dex,ou=Services,dc=eom,dc=dev"
- "cn=Discourse Users,ou=Discourse,ou=Services,dc=eom,dc=dev"
- "cn=Gitea Users,ou=Gitea,ou=Services,dc=eom,dc=dev"
- "cn=Matrix Users,ou=Matrix,ou=Services,dc=eom,dc=dev"
- "cn=Mastodon Users,ou=Mastodon,ou=Services,dc=eom,dc=dev"
- "cn=PeerTube Users,ou=PeerTube,ou=Services,dc=eom,dc=dev"
"5":
- "cn=Dex Users,ou=Dex,ou=Services,dc=eom,dc=dev"
- "cn=Discourse Users,ou=Discourse,ou=Services,dc=eom,dc=dev"
- "cn=Gitea Users,ou=Gitea,ou=Services,dc=eom,dc=dev"
- "cn=Matrix Users,ou=Matrix,ou=Services,dc=eom,dc=dev"
- "cn=Mastodon Users,ou=Mastodon,ou=Services,dc=eom,dc=dev"
- "cn=PeerTube Users,ou=PeerTube,ou=Services,dc=eom,dc=dev"
# Public settings only. Credentials belong in the membership Secret. # Public settings only. Credentials belong in the membership Secret.
www_membership_environment: www_membership_environment:
MEMBERSHIP_PUBLIC_URL: "https://{{ www_hostname }}" MEMBERSHIP_PUBLIC_URL: "https://{{ www_hostname }}"
MEMBERSHIP_ALLOW_HTTP: "0" MEMBERSHIP_ALLOW_HTTP: "0"
MEMBERSHIP_TRUST_PROXY: "1" MEMBERSHIP_TRUST_PROXY: "1"
MEMBERSHIP_TIERS_FILE: /etc/membership/tiers/tiers.json MEMBERSHIP_TIERS_FILE: /etc/membership/tiers/tiers.json
MEMBERSHIP_SERVICE_GROUPS_FILE: /etc/membership/tiers/service-groups.json
POSTGRES_HOST: membership-db POSTGRES_HOST: membership-db
POSTGRES_PORT: "5432" POSTGRES_PORT: "5432"
POSTGRES_DB: membership POSTGRES_DB: membership
@@ -68,8 +112,11 @@ www_membership_environment:
POSTGRES_SSLMODE: prefer POSTGRES_SSLMODE: prefer
DEX_ISSUER: https://dex.example.com DEX_ISSUER: https://dex.example.com
DEX_CLIENT_ID: www-membership DEX_CLIENT_ID: www-membership
DEX_CONNECTOR_ID: www-ldap DEX_CONNECTOR_ID: ldap
LDAP_URL: ldaps://ldap.example.com LDAP_URL: ldaps://ldap.example.com
LDAP_ALLOW_INSECURE: "0"
# Used only for registration/group sync, not Dex login callbacks.
# Its plaintext bind password belongs in Secret LDAP_BIND_PASSWORD, not here.
LDAP_BIND_DN: "" LDAP_BIND_DN: ""
LDAP_ACCOUNT_BASE_DN: ou=www,ou=people,dc=example,dc=com LDAP_ACCOUNT_BASE_DN: ou=www,ou=people,dc=example,dc=com
LDAP_SEARCH_BASE_DN: ou=people,dc=example,dc=com LDAP_SEARCH_BASE_DN: ou=people,dc=example,dc=com
@@ -86,37 +133,87 @@ www_membership_environment:
SHKEEPER_URL: "" SHKEEPER_URL: ""
SHKEEPER_CURRENCIES: "" SHKEEPER_CURRENCIES: ""
SHKEEPER_CA_CERT_FILE: "" SHKEEPER_CA_CERT_FILE: ""
# Newsletter opt-in at signup and on the profile. Enabled only when the
# LISTMONK_API_TOKEN Secret key is also present.
LISTMONK_URL: ""
LISTMONK_API_USER: ""
LISTMONK_LIST_NAME: eom.dev
# Existing LIVE Stripe prices. Provision the dedicated group DNs separately. # Existing LIVE Stripe prices. Level 0 is free and is not configured as a paid tier.
# Provision the dedicated paid-group DNs separately.
# Override with {} to disable paid tiers, or test-mode prices for a sandbox. # Override with {} to disable paid tiers, or test-mode prices for a sandbox.
www_membership_tiers: www_membership_tiers:
level-0: level-1:
label: Level 0 level: 1
label: Bronze
price_id: price_1Rgv4xC5KPXqTvVLfMd6z7wn price_id: price_1Rgv4xC5KPXqTvVLfMd6z7wn
price_label: $4 USD / month price_label: $4 USD / month
usd_amount: "4.00" usd_amount: "4.00"
group_dn: cn=www-level-0,ou=groups,dc=eom,dc=dev group_dn: cn=Level 1,ou=Memberships,dc=eom,dc=dev
level-1: description: Kick off the experiment with a private place to connect and a closer look at what is being made.
label: Level 1 # Display benefits only; exclusive-content permissions are managed manually.
benefits:
- Exclusive members-only content on Discourse and Matrix
- Behind-the-scenes notes and work-in-progress updates
- Giveaways exclusive to Bronze members
- Optional credit on the community supporters page
level-2:
level: 2
label: Silver
price_id: price_1Rgv5QC5KPXqTvVLv1PaHWb5 price_id: price_1Rgv5QC5KPXqTvVLv1PaHWb5
price_label: $8 USD / month price_label: $8 USD / month
usd_amount: "8.00" usd_amount: "8.00"
group_dn: cn=www-level-1,ou=groups,dc=eom,dc=dev group_dn: cn=Level 2,ou=Memberships,dc=eom,dc=dev
level-2: description: Venture behind the scenes and explore new experiments before their public release.
label: Level 2 benefits:
- Bronze content and community benefits
- Giveaways exclusive to Silver members
- Early previews of music, games, videos and experiments when available
- Invitations to member playtests and listening sessions
level-3:
level: 3
label: Gold
price_id: price_1Rgv5rC5KPXqTvVL8Ol8sMUf price_id: price_1Rgv5rC5KPXqTvVL8Ol8sMUf
price_label: $16 USD / month price_label: $16 USD / month
usd_amount: "16.00" usd_amount: "16.00"
group_dn: cn=www-level-2,ou=groups,dc=eom,dc=dev group_dn: cn=Level 3,ou=Memberships,dc=eom,dc=dev
level-3: description: Bring your perspective and help shape the next community experiment.
label: Level 3 benefits:
- Silver content and community benefits
- Giveaways exclusive to Gold members
- Member polls on future project topics and experiments
- Invitations to occasional small-group feedback sessions
level-4:
level: 4
label: Platinum
price_id: price_1Rgv6FC5KPXqTvVL39mAxYjm price_id: price_1Rgv6FC5KPXqTvVL39mAxYjm
price_label: $32 USD / month price_label: $32 USD / month
usd_amount: "32.00" usd_amount: "32.00"
group_dn: cn=www-level-3,ou=groups,dc=eom,dc=dev group_dn: cn=Level 4,ou=Memberships,dc=eom,dc=dev
level-4: description: Support the platform and help guide its direction through exclusive polls.
label: Level 4 benefits:
- Gold content and community benefits
- Giveaways exclusive to Platinum members
- Exclusive polls on the direction of the platform
- A larger contribution toward servers and public projects
- Choose this level if you would like to give more
level-5:
level: 5
label: Diamond
price_id: price_1Rgv6mC5KPXqTvVLkcmznWZa price_id: price_1Rgv6mC5KPXqTvVLkcmznWZa
price_label: $64 USD / month price_label: $64 USD / month
usd_amount: "64.00" usd_amount: "64.00"
group_dn: cn=www-level-4,ou=groups,dc=eom,dc=dev group_dn: cn=Level 5,ou=Memberships,dc=eom,dc=dev
description: Help underwrite the public work and the community around it.
benefits:
- Gold content and community benefits
- Giveaways exclusive to Diamond members
- Exclusive polls on the direction of the platform
- Optional Diamond credit on the supporters page
- Your biggest boost for independent public projects
# Create any missing tier/service groups (with the placeholder member) in the
# OpenLDAP pod. Existing groups and their members are left untouched.
www_membership_ldap_ensure_groups: false
www_membership_ldap_namespace: openldap
www_membership_ldap_pod_selector: app=openldap
+44 -2
View File
@@ -13,10 +13,13 @@
- "www_membership_image is search('(:[^/]+$|@sha256:[a-f0-9]{64}$)')" - "www_membership_image is search('(:[^/]+$|@sha256:[a-f0-9]{64}$)')"
- www_membership_environment is mapping - www_membership_environment is mapping
- www_membership_tiers is mapping - www_membership_tiers is mapping
- www_membership_service_groups is mapping
- www_membership_environment.MEMBERSHIP_PUBLIC_URL == 'https://' ~ www_hostname - www_membership_environment.MEMBERSHIP_PUBLIC_URL == 'https://' ~ www_hostname
- www_membership_environment.MEMBERSHIP_ALLOW_HTTP | string == '0' - www_membership_environment.MEMBERSHIP_ALLOW_HTTP | string == '0'
- www_membership_environment.MEMBERSHIP_TRUST_PROXY | string == '1' - www_membership_environment.MEMBERSHIP_TRUST_PROXY | string == '1'
- www_membership_environment.MEMBERSHIP_TIERS_FILE == '/etc/membership/tiers/tiers.json' - www_membership_environment.MEMBERSHIP_TIERS_FILE == '/etc/membership/tiers/tiers.json'
- www_membership_environment.MEMBERSHIP_SERVICE_GROUPS_FILE == '/etc/membership/tiers/service-groups.json'
- "'MEMBERSHIP_SERVICE_GROUPS_JSON' not in www_membership_environment"
- "'MEMBERSHIP_SQLITE_PATH' not in www_membership_environment" - "'MEMBERSHIP_SQLITE_PATH' not in www_membership_environment"
- "'MEMBERSHIP_TIERS_JSON' not in www_membership_environment" - "'MEMBERSHIP_TIERS_JSON' not in www_membership_environment"
- www_membership_replicas | int >= 1 - www_membership_replicas | int >= 1
@@ -27,14 +30,27 @@
- www_membership_environment.EMAIL_USE_TLS | string == '1' - www_membership_environment.EMAIL_USE_TLS | string == '1'
- www_membership_environment.DEX_ISSUER is match('^https://[^/@]+') - www_membership_environment.DEX_ISSUER is match('^https://[^/@]+')
- www_membership_environment.LDAP_URL is match('^ldaps?://[^/@]+') - www_membership_environment.LDAP_URL is match('^ldaps?://[^/@]+')
- (www_membership_environment.LDAP_ALLOW_INSECURE | default('0') | string) in ['0', '1']
- not www_membership_shkeeper_enabled or (www_membership_environment.SHKEEPER_URL is match('^https://[^/@]+') and www_membership_environment.SHKEEPER_CURRENCIES | length > 0) - not www_membership_shkeeper_enabled or (www_membership_environment.SHKEEPER_URL is match('^https://[^/@]+') and www_membership_environment.SHKEEPER_CURRENCIES | length > 0)
- www_membership_shkeeper_enabled or (www_membership_environment.SHKEEPER_URL | length == 0 and www_membership_environment.SHKEEPER_CURRENCIES | length == 0) - www_membership_shkeeper_enabled or (www_membership_environment.SHKEEPER_URL | length == 0 and www_membership_environment.SHKEEPER_CURRENCIES | length == 0)
- not www_membership_stripe_enabled or www_membership_environment.STRIPE_PORTAL_CONFIGURATION | length > 0 - not www_membership_stripe_enabled or www_membership_environment.STRIPE_PORTAL_CONFIGURATION | length > 0
- (www_membership_environment.LISTMONK_URL | default('')) | length == 0 or www_membership_environment.LISTMONK_URL is match('^https://[^/@]+$')
- not www_membership_postgres_managed or www_membership_environment.POSTGRES_HOST == 'membership-db' - not www_membership_postgres_managed or www_membership_environment.POSTGRES_HOST == 'membership-db'
- www_membership_postgres_managed or www_membership_environment.POSTGRES_SSLMODE == 'verify-full' - www_membership_postgres_managed or www_membership_environment.POSTGRES_SSLMODE == 'verify-full'
- not www_membership_postgres_managed or www_membership_environment.POSTGRES_PORT | string == '5432' - not www_membership_postgres_managed or www_membership_environment.POSTGRES_PORT | string == '5432'
fail_msg: Supply a versioned membership image and configure verified LDAP, SMTP, Dex, database and payment settings (see role README). fail_msg: Supply a versioned membership image and configure verified LDAP, SMTP, Dex, database and payment settings (see role README).
- name: Validate service group level mappings
tags: [membership_validate]
ansible.builtin.assert:
that:
- item.key | string is match('^(0|[1-9][0-9]*)$')
- item.key | string in (['0'] + (www_membership_tiers.values() | map(attribute='level') | map('string') | list))
- item.value is sequence and item.value is not string and item.value is not mapping
- item.value | select('string') | list | length == item.value | length
fail_msg: Service groups must map explicit configured levels (including 0) to lists of LDAP group DNs.
loop: "{{ www_membership_service_groups | dict2items }}"
- name: Validate optional CA file mount paths - name: Validate optional CA file mount paths
tags: [membership_validate] tags: [membership_validate]
ansible.builtin.assert: ansible.builtin.assert:
@@ -46,7 +62,7 @@
- name: Define secret-only settings - name: Define secret-only settings
tags: [membership_validate] tags: [membership_validate]
ansible.builtin.set_fact: ansible.builtin.set_fact:
_www_secret_keys: [MEMBERSHIP_SECRET_KEY, POSTGRES_PASSWORD, DEX_CLIENT_SECRET, LDAP_BIND_PASSWORD, EMAIL_HOST_PASSWORD, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, SHKEEPER_API_KEY] _www_secret_keys: [MEMBERSHIP_SECRET_KEY, POSTGRES_PASSWORD, DEX_CLIENT_SECRET, LDAP_BIND_PASSWORD, EMAIL_HOST_PASSWORD, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, SHKEEPER_API_KEY, LISTMONK_API_TOKEN]
_www_required_secret_keys: "{{ ['MEMBERSHIP_SECRET_KEY', 'POSTGRES_PASSWORD', 'DEX_CLIENT_SECRET', 'LDAP_BIND_PASSWORD'] + (['STRIPE_SECRET_KEY', 'STRIPE_WEBHOOK_SECRET'] if www_membership_stripe_enabled else []) + (['SHKEEPER_API_KEY'] if www_membership_shkeeper_enabled else []) }}" _www_required_secret_keys: "{{ ['MEMBERSHIP_SECRET_KEY', 'POSTGRES_PASSWORD', 'DEX_CLIENT_SECRET', 'LDAP_BIND_PASSWORD'] + (['STRIPE_SECRET_KEY', 'STRIPE_WEBHOOK_SECRET'] if www_membership_stripe_enabled else []) + (['SHKEEPER_API_KEY'] if www_membership_shkeeper_enabled else []) }}"
- name: Prevent secrets in public ConfigMaps - name: Prevent secrets in public ConfigMaps
@@ -136,7 +152,7 @@
- name: Compute configuration revision without secret contents - name: Compute configuration revision without secret contents
# resourceVersion changes trigger a rollout without exposing secret values. # resourceVersion changes trigger a rollout without exposing secret values.
ansible.builtin.set_fact: ansible.builtin.set_fact:
_www_membership_revision: "{{ {'image': www_membership_image, 'environment': www_membership_environment, 'tiers': www_membership_tiers, 'secret_name': www_membership_secret_name, 'secret_version': (_www_membership_secret.resources[0].metadata.resourceVersion if not ansible_check_mode else 'check'), 'ca_name': www_membership_ca_secret_name, 'ca_version': (_www_membership_ca.resources[0].metadata.resourceVersion if www_membership_ca_secret_name | length > 0 and not ansible_check_mode else ''), 'rollout': www_rollout_revision} | to_json(sort_keys=True) | hash('sha256') }}" _www_membership_revision: "{{ {'image': www_membership_image, 'environment': www_membership_environment, 'tiers': www_membership_tiers, 'service_groups': www_membership_service_groups, 'secret_name': www_membership_secret_name, 'secret_version': (_www_membership_secret.resources[0].metadata.resourceVersion if not ansible_check_mode else 'check'), 'ca_name': www_membership_ca_secret_name, 'ca_version': (_www_membership_ca.resources[0].metadata.resourceVersion if www_membership_ca_secret_name | length > 0 and not ansible_check_mode else ''), 'rollout': www_rollout_revision} | to_json(sort_keys=True) | hash('sha256') }}"
no_log: true no_log: true
- name: Deploy public membership configuration and network isolation - name: Deploy public membership configuration and network isolation
@@ -192,3 +208,29 @@
status: "True" status: "True"
loop: [membership, membership-worker] loop: [membership, membership-worker]
when: not ansible_check_mode when: not ansible_check_mode
- name: Find the OpenLDAP pod
kubernetes.core.k8s_info:
kind: Pod
namespace: "{{ www_membership_ldap_namespace }}"
label_selectors: ["{{ www_membership_ldap_pod_selector }}"]
field_selectors: [status.phase=Running]
register: www_ldap_pods
when: www_membership_ldap_ensure_groups | bool
- name: Ensure membership tier and service groups exist in LDAP
kubernetes.core.k8s_exec:
namespace: "{{ www_membership_ldap_namespace }}"
pod: "{{ www_ldap_pods.resources[0].metadata.name }}"
command: >-
sh -c 'printf "%s\n" "dn: {{ item }}" "objectClass: groupOfUniqueNames"
"cn: {{ (item | split(","))[0] | regex_replace("^cn=", "") }}"
"uniqueMember: {{ (www_membership_environment | combine(www_membership_environment_overrides)).LDAP_GROUP_PLACEHOLDER_DN }}"
| ldapadd -x -H ldap://localhost -D "cn=admin,dc=eom,dc=dev" -w "$LDAP_ADMIN_PASSWORD"; rc=$?;
if [ $rc -eq 0 ]; then echo GROUP_CREATED; elif [ $rc -ne 68 ]; then exit $rc; fi'
loop: >-
{{ ((www_membership_tiers.values() | map(attribute='group_dn') | list)
+ (www_membership_service_groups.values() | flatten)) | unique }}
register: www_ldap_groups
changed_when: "'GROUP_CREATED' in (www_ldap_groups.stdout | default(''))"
when: www_membership_ldap_ensure_groups | bool
+1
View File
@@ -15,6 +15,7 @@ metadata:
namespace: {{ www_namespace }} namespace: {{ www_namespace }}
data: data:
tiers.json: {{ www_membership_tiers | to_json | to_json }} tiers.json: {{ www_membership_tiers | to_json | to_json }}
service-groups.json: {{ www_membership_service_groups | to_json | to_json }}
--- ---
# Only Apache may assert the trusted proxy headers to the membership service. # Only Apache may assert the trusted proxy headers to the membership service.
# Requires a CNI which actually enforces NetworkPolicy. # Requires a CNI which actually enforces NetworkPolicy.
+7
View File
@@ -1,6 +1,9 @@
{% if www_membership_enabled %} {% if www_membership_enabled %}
ProxyRequests Off ProxyRequests Off
ProxyPreserveHost On ProxyPreserveHost On
# Service sign-up buttons link to /members; the app lives under /members/.
# Absolute target: TLS ends at ingress, so a relative one becomes http://...:443
RedirectMatch 301 "^/members$" "https://{{ www_hostname }}/members/"
ProxyPass "/members/" "http://membership:8000/members/" connectiontimeout=5 timeout=120 ProxyPass "/members/" "http://membership:8000/members/" connectiontimeout=5 timeout=120
ProxyPassReverse "/members/" "http://membership:8000/members/" ProxyPassReverse "/members/" "http://membership:8000/members/"
<Location "/members/"> <Location "/members/">
@@ -14,6 +17,10 @@ ProxyPassReverse "/members/" "http://membership:8000/members/"
Require all denied Require all denied
</If> </If>
</Location> </Location>
<Location "/members/profile/">
# Bounded JPEG upload plus multipart overhead; other routes remain 256 KiB.
LimitRequestBody 2097152
</Location>
{% else %} {% else %}
# Membership proxy disabled. Existing stateful workloads are not deleted. # Membership proxy disabled. Existing stateful workloads are not deleted.
<Location "/members/"> <Location "/members/">
+38 -1
View File
@@ -152,7 +152,8 @@ metadata:
# Redact email verification tokens and OIDC codes at the edge, not only in # Redact email verification tokens and OIDC codes at the edge, not only in
# Apache/Gunicorn. Do not enable request-body/error tracing for /members/. # Apache/Gunicorn. Do not enable request-body/error tracing for /members/.
nginx.ingress.kubernetes.io/enable-access-log: "false" nginx.ingress.kubernetes.io/enable-access-log: "false"
nginx.ingress.kubernetes.io/proxy-body-size: "256k" # Apache retains 256 KiB for non-profile member routes.
nginx.ingress.kubernetes.io/proxy-body-size: "2m"
nginx.ingress.kubernetes.io/proxy-read-timeout: "120" nginx.ingress.kubernetes.io/proxy-read-timeout: "120"
spec: spec:
ingressClassName: {{ www_ingress_class }} ingressClassName: {{ www_ingress_class }}
@@ -170,3 +171,39 @@ spec:
tls: tls:
- hosts: [{{ www_hostname }}] - hosts: [{{ www_hostname }}]
secretName: www secretName: www
{% if www_redirect_hostnames | length > 0 %}
---
# Send browsers on alias hostnames (e.g. the apex, whose /.well-known/matrix/*
# paths are owned by another ingress) to the canonical origin. Exact/longer
# paths on the same host in other ingresses still take precedence over "/".
# A snippet is used because the controller's annotation validation rejects
# "$request_uri" in permanent-redirect.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: www-redirect
namespace: {{ www_namespace }}
annotations:
cert-manager.io/cluster-issuer: {{ www_cluster_issuer | to_json }}
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/enable-access-log: "false"
nginx.ingress.kubernetes.io/configuration-snippet: {{ ("return 301 https://" ~ www_hostname ~ "$request_uri;") | to_json }}
spec:
ingressClassName: {{ www_ingress_class }}
rules:
{% for host in www_redirect_hostnames %}
- host: {{ host }}
http:
paths:
- pathType: Prefix
path: /
backend:
service:
name: httpd
port:
number: 80
{% endfor %}
tls:
- hosts: {{ www_redirect_hostnames | to_json }}
secretName: www-redirect
{% endif %}
+13
View File
@@ -4,8 +4,21 @@ www_hostname: www.eom.dev
www_membership_image: registry.example.org/www-membership:2026-10-23 www_membership_image: registry.example.org/www-membership:2026-10-23
www_repo_version: refs/heads/main www_repo_version: refs/heads/main
www_membership_secret_name: www-membership-credentials www_membership_secret_name: www-membership-credentials
# Examples only: create dedicated groupOfUniqueNames entries and ACLs first.
# No automatic inheritance: include each group at every permitted level.
www_membership_service_groups:
"0": ["cn=community-chat,ou=membership-services,dc=eom,dc=dev"]
"1": ["cn=community-chat,ou=membership-services,dc=eom,dc=dev"]
"2": ["cn=community-chat,ou=membership-services,dc=eom,dc=dev", "cn=game-servers,ou=membership-services,dc=eom,dc=dev"]
"3": ["cn=community-chat,ou=membership-services,dc=eom,dc=dev", "cn=game-servers,ou=membership-services,dc=eom,dc=dev"]
"4": ["cn=community-chat,ou=membership-services,dc=eom,dc=dev", "cn=game-servers,ou=membership-services,dc=eom,dc=dev"]
"5": ["cn=community-chat,ou=membership-services,dc=eom,dc=dev", "cn=game-servers,ou=membership-services,dc=eom,dc=dev"]
www_membership_shkeeper_enabled: true www_membership_shkeeper_enabled: true
www_membership_environment_overrides: www_membership_environment_overrides:
# Optional plaintext LDAP for a trusted cluster only; sends passwords unencrypted.
# Enable BOTH lines together. Omit them to retain the role's verified TLS default.
# LDAP_URL: ldap://openldap.openldap.svc.cluster.local:389
# LDAP_ALLOW_INSECURE: "1"
LDAP_BIND_DN: cn=www-membership,ou=system,dc=eom,dc=dev LDAP_BIND_DN: cn=www-membership,ou=system,dc=eom,dc=dev
LDAP_PASSWORD_SCHEME: "{ARGON2}" LDAP_PASSWORD_SCHEME: "{ARGON2}"
EMAIL_HOST: smtp.eom.dev EMAIL_HOST: smtp.eom.dev
+13 -1
View File
@@ -11,6 +11,10 @@
- name: Supply nonsecret fixture settings - name: Supply nonsecret fixture settings
ansible.builtin.set_fact: ansible.builtin.set_fact:
www_membership_image: registry.example.org/www-membership:release-test www_membership_image: registry.example.org/www-membership:release-test
www_redirect_hostnames: [apex.example.com]
www_membership_service_groups:
"0": ["cn=free-service,ou=groups,dc=example,dc=org"]
"2": ["cn=paid-service,ou=groups,dc=example,dc=org", "cn=second-service,ou=groups,dc=example,dc=org"]
_www_membership_revision: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa _www_membership_revision: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
www_membership_environment: "{{ www_membership_environment | combine({'MEMBERSHIP_PUBLIC_URL': 'https://' ~ www_hostname, 'LDAP_BIND_DN': 'cn=membership,dc=example,dc=org', 'LDAP_PASSWORD_SCHEME': '{ARGON2}', 'EMAIL_HOST': 'smtp.example.org', 'STRIPE_PORTAL_CONFIGURATION': 'bpc_test'}) }}" www_membership_environment: "{{ www_membership_environment | combine({'MEMBERSHIP_PUBLIC_URL': 'https://' ~ www_hostname, 'LDAP_BIND_DN': 'cn=membership,dc=example,dc=org', 'LDAP_PASSWORD_SCHEME': '{ARGON2}', 'EMAIL_HOST': 'smtp.example.org', 'STRIPE_PORTAL_CONFIGURATION': 'bpc_test'}) }}"
- name: Render Apache proxy fixture - name: Render Apache proxy fixture
@@ -27,12 +31,20 @@
dest: "/tmp/www-test-{{ item }}.yml" dest: "/tmp/www-test-{{ item }}.yml"
mode: "0600" mode: "0600"
loop: [website, membership-config, membership-postgres, membership-migrate, membership-workloads] loop: [website, membership-config, membership-postgres, membership-migrate, membership-workloads]
- name: Enable explicit plaintext LDAP fixture
ansible.builtin.set_fact:
www_membership_environment: "{{ www_membership_environment | combine({'LDAP_URL': 'ldap://openldap.openldap.svc.cluster.local:389', 'LDAP_ALLOW_INSECURE': '1'}) }}"
- name: Render plaintext LDAP configuration
ansible.builtin.template:
src: ../templates/membership-config.yml.j2
dest: /tmp/www-test-plaintext-membership-config.yml
mode: "0600"
- name: Enable optional external database and private CA fixture - name: Enable optional external database and private CA fixture
ansible.builtin.set_fact: ansible.builtin.set_fact:
www_membership_postgres_managed: false www_membership_postgres_managed: false
www_membership_ca_secret_name: private-ca www_membership_ca_secret_name: private-ca
www_image_pull_secrets: [{name: registry-pull}] www_image_pull_secrets: [{name: registry-pull}]
www_membership_environment: "{{ www_membership_environment | combine({'POSTGRES_HOST': 'postgres.example.org', 'POSTGRES_SSLMODE': 'verify-full', 'PGSSLROOTCERT': '/etc/membership/ca/db.pem', 'LDAP_CA_CERT_FILE': '/etc/membership/ca/ldap.pem', 'SHKEEPER_URL': 'https://crypto.example.org', 'SHKEEPER_CURRENCIES': 'BTC,XMR', 'SHKEEPER_CA_CERT_FILE': '/etc/membership/ca/shkeeper.pem'}) }}" www_membership_environment: "{{ www_membership_environment | combine({'LDAP_URL': 'ldaps://ldap.example.com', 'LDAP_ALLOW_INSECURE': '0', 'POSTGRES_HOST': 'postgres.example.org', 'POSTGRES_SSLMODE': 'verify-full', 'PGSSLROOTCERT': '/etc/membership/ca/db.pem', 'LDAP_CA_CERT_FILE': '/etc/membership/ca/ldap.pem', 'SHKEEPER_URL': 'https://crypto.example.org', 'SHKEEPER_CURRENCIES': 'BTC,XMR', 'SHKEEPER_CA_CERT_FILE': '/etc/membership/ca/shkeeper.pem'}) }}"
- name: Render external provider variants - name: Render external provider variants
ansible.builtin.template: ansible.builtin.template:
src: "../templates/{{ item }}.yml.j2" src: "../templates/{{ item }}.yml.j2"
+21 -2
View File
@@ -43,9 +43,20 @@ assert len({(obj["kind"], obj["metadata"]["name"]) for obj in all_objects}) == l
config = find(all_objects, "ConfigMap", "membership-config")["data"] config = find(all_objects, "ConfigMap", "membership-config")["data"]
assert all(isinstance(value, str) for value in config.values()) assert all(isinstance(value, str) for value in config.values())
assert config["MEMBERSHIP_PUBLIC_URL"] == "https://www.eom.dev" assert config["MEMBERSHIP_PUBLIC_URL"] == "https://www.eom.dev"
assert config["LDAP_ALLOW_INSECURE"] == "0"
assert config["LDAP_URL"].startswith("ldaps://")
plaintext_config = find(docs("plaintext-membership-config"), "ConfigMap", "membership-config")["data"]
assert plaintext_config["LDAP_ALLOW_INSECURE"] == "1"
assert plaintext_config["LDAP_URL"] == "ldap://openldap.openldap.svc.cluster.local:389"
assert all(isinstance(value, str) for value in plaintext_config.values())
assert "MEMBERSHIP_SECRET_KEY" not in config and "SHKEEPER_API_KEY" not in config assert "MEMBERSHIP_SECRET_KEY" not in config and "SHKEEPER_API_KEY" not in config
tiers = json.loads(find(all_objects, "ConfigMap", "membership-tiers")["data"]["tiers.json"]) tiers = json.loads(find(all_objects, "ConfigMap", "membership-tiers")["data"]["tiers.json"])
assert len(tiers) == 5 and tiers["level-4"]["usd_amount"] == "64.00" service_groups = json.loads(find(all_objects, "ConfigMap", "membership-tiers")["data"]["service-groups.json"])
assert config["MEMBERSHIP_SERVICE_GROUPS_FILE"] == "/etc/membership/tiers/service-groups.json"
assert service_groups["0"] == ["cn=free-service,ou=groups,dc=example,dc=org"]
assert len(service_groups["2"]) == 2
assert len(tiers) == 5 and tiers["level-5"]["usd_amount"] == "64.00"
assert [tiers[f"level-{level}"]["level"] for level in range(1, 6)] == list(range(1, 6))
for name in ["membership", "membership-worker"]: for name in ["membership", "membership-worker"]:
deployment = find(all_objects, "Deployment", name) deployment = find(all_objects, "Deployment", name)
pod_checks(deployment["spec"]["template"]["spec"]) pod_checks(deployment["spec"]["template"]["spec"])
@@ -69,6 +80,9 @@ apache = find(all_objects, "ConfigMap", "httpd")["data"]
assert "http://membership:8000/members/" in apache["membership.conf"] assert "http://membership:8000/members/" in apache["membership.conf"]
assert "X-Forwarded-Proto" in apache["membership.conf"] assert "X-Forwarded-Proto" in apache["membership.conf"]
assert "Require all denied" in apache["membership.conf"] assert "Require all denied" in apache["membership.conf"]
assert "LimitRequestBody 262144" in apache["membership.conf"]
assert '<Location "/members/profile/">' in apache["membership.conf"]
assert "LimitRequestBody 2097152" in apache["membership.conf"]
assert "env=!membership_sensitive" in apache["httpd.conf"] assert "env=!membership_sensitive" in apache["httpd.conf"]
assert "LoadModule proxy_http_module" in apache["httpd.conf"] assert "LoadModule proxy_http_module" in apache["httpd.conf"]
source = find(all_objects, "Deployment", "httpd")["spec"]["template"]["spec"]["initContainers"][0] source = find(all_objects, "Deployment", "httpd")["spec"]["template"]["spec"]["initContainers"][0]
@@ -76,14 +90,19 @@ assert "rm -rf" not in source["command"][-1]
assert 'cp -a /tmp/www/htdocs/. /data/' in source["command"][-1] assert 'cp -a /tmp/www/htdocs/. /data/' in source["command"][-1]
ingress = find(all_objects, "Ingress", "www") ingress = find(all_objects, "Ingress", "www")
assert ingress["metadata"]["annotations"]["nginx.ingress.kubernetes.io/enable-access-log"] == "false" assert ingress["metadata"]["annotations"]["nginx.ingress.kubernetes.io/enable-access-log"] == "false"
assert ingress["metadata"]["annotations"]["nginx.ingress.kubernetes.io/proxy-body-size"] == "2m"
assert ingress["spec"]["rules"][0]["http"]["paths"][0]["backend"]["service"]["name"] == "httpd" assert ingress["spec"]["rules"][0]["http"]["paths"][0]["backend"]["service"]["name"] == "httpd"
redirect = find(all_objects, "Ingress", "www-redirect")
assert redirect["spec"]["rules"][0]["host"] == "apex.example.com"
assert redirect["metadata"]["annotations"]["nginx.ingress.kubernetes.io/configuration-snippet"] == "return 301 " + config["MEMBERSHIP_PUBLIC_URL"] + "$request_uri;"
for obj in docs("external-membership-workloads"): for obj in docs("external-membership-workloads"):
if obj["kind"] == "Deployment": if obj["kind"] == "Deployment":
pod_checks(obj["spec"]["template"]["spec"], external=True) pod_checks(obj["spec"]["template"]["spec"], external=True)
external_config = docs("external-membership-config") external_config = docs("external-membership-config")
assert not any(obj["kind"] == "NetworkPolicy" and obj["metadata"]["name"] == "membership-db" for obj in external_config) assert not any(obj["kind"] == "NetworkPolicy" and obj["metadata"]["name"] == "membership-db" for obj in external_config)
assert find(external_config, "ConfigMap", "membership-config")["data"]["POSTGRES_SSLMODE"] == "verify-full" assert find(external_config, "ConfigMap", "membership-config")["data"]["POSTGRES_SSLMODE"] == "verify-full"
assert find(external_config, "ConfigMap", "membership-config")["data"]["LDAP_ALLOW_INSECURE"] == "0"
disabled = docs("disabled-website") disabled = docs("disabled-website")
assert not any(obj["kind"] == "OnionService" for obj in disabled) assert not any(obj["kind"] == "OnionService" for obj in disabled)
assert "ProxyPass" not in find(disabled, "ConfigMap", "httpd")["data"]["membership.conf"] assert "ProxyPass" not in find(disabled, "ConfigMap", "httpd")["data"]["membership.conf"]
print(f"PASS: {len(all_objects)} Kubernetes objects; default, external/CA/SHKeeper and disabled variants") print(f"PASS: {len(all_objects)} Kubernetes objects; default, plaintext LDAP, external/CA/SHKeeper and disabled variants")