diff --git a/README.md b/README.md index 79e6990..1b70d2f 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,44 @@ the application. Level 0 is the free account baseline; paid offers are levels groupOfUniqueNames groups, sentinel and ACLs separately. Override group DNs if 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 Create an Opaque Secret using External Secrets, Sealed Secrets or your secret @@ -124,12 +162,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 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 `LDAP_CA_CERT_FILE`, `SHKEEPER_CA_CERT_FILE`, `PGSSLROOTCERT` or `REQUESTS_CA_BUNDLE` to `/etc/membership/ca/`. The read-only mount is shared by web, worker and migrations. Verify certificate expiry, strong keys, 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 [tests/example-vars.yml](tests/example-vars.yml) contains public configuration diff --git a/defaults/main.yml b/defaults/main.yml index 35ddfd6..b5704d3 100644 --- a/defaults/main.yml +++ b/defaults/main.yml @@ -55,12 +55,18 @@ www_membership_stripe_enabled: false www_membership_shkeeper_enabled: false 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: {} + # Public settings only. Credentials belong in the membership Secret. www_membership_environment: MEMBERSHIP_PUBLIC_URL: "https://{{ www_hostname }}" MEMBERSHIP_ALLOW_HTTP: "0" MEMBERSHIP_TRUST_PROXY: "1" MEMBERSHIP_TIERS_FILE: /etc/membership/tiers/tiers.json + MEMBERSHIP_SERVICE_GROUPS_FILE: /etc/membership/tiers/service-groups.json POSTGRES_HOST: membership-db POSTGRES_PORT: "5432" POSTGRES_DB: membership @@ -71,6 +77,8 @@ www_membership_environment: DEX_CONNECTOR_ID: ldap 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_ACCOUNT_BASE_DN: ou=www,ou=people,dc=example,dc=com LDAP_SEARCH_BASE_DN: ou=people,dc=example,dc=com diff --git a/tasks/membership.yml b/tasks/membership.yml index a1d6cb6..fa0785b 100755 --- a/tasks/membership.yml +++ b/tasks/membership.yml @@ -13,10 +13,13 @@ - "www_membership_image is search('(:[^/]+$|@sha256:[a-f0-9]{64}$)')" - www_membership_environment 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_ALLOW_HTTP | string == '0' - www_membership_environment.MEMBERSHIP_TRUST_PROXY | string == '1' - 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_TIERS_JSON' not in www_membership_environment" - www_membership_replicas | int >= 1 @@ -36,6 +39,17 @@ - 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). +- 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 tags: [membership_validate] ansible.builtin.assert: @@ -137,7 +151,7 @@ - name: Compute configuration revision without secret contents # resourceVersion changes trigger a rollout without exposing secret values. 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 - name: Deploy public membership configuration and network isolation diff --git a/templates/membership-config.yml.j2 b/templates/membership-config.yml.j2 index 2218c57..7eadf2d 100755 --- a/templates/membership-config.yml.j2 +++ b/templates/membership-config.yml.j2 @@ -15,6 +15,7 @@ metadata: namespace: {{ www_namespace }} data: 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. # Requires a CNI which actually enforces NetworkPolicy. diff --git a/templates/membership-httpd.conf.j2 b/templates/membership-httpd.conf.j2 index a6e9c7f..8a3b8c4 100755 --- a/templates/membership-httpd.conf.j2 +++ b/templates/membership-httpd.conf.j2 @@ -14,6 +14,10 @@ ProxyPassReverse "/members/" "http://membership:8000/members/" Require all denied + + # Bounded JPEG upload plus multipart overhead; other routes remain 256 KiB. + LimitRequestBody 2097152 + {% else %} # Membership proxy disabled. Existing stateful workloads are not deleted. diff --git a/templates/website.yml.j2 b/templates/website.yml.j2 index 1d630b7..83bd64b 100755 --- a/templates/website.yml.j2 +++ b/templates/website.yml.j2 @@ -152,7 +152,8 @@ metadata: # Redact email verification tokens and OIDC codes at the edge, not only in # Apache/Gunicorn. Do not enable request-body/error tracing for /members/. 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" spec: ingressClassName: {{ www_ingress_class }} diff --git a/tests/example-vars.yml b/tests/example-vars.yml index 45db928..bd4b476 100755 --- a/tests/example-vars.yml +++ b/tests/example-vars.yml @@ -4,6 +4,15 @@ www_hostname: www.eom.dev www_membership_image: registry.example.org/www-membership:2026-10-23 www_repo_version: refs/heads/main 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_environment_overrides: # Optional plaintext LDAP for a trusted cluster only; sends passwords unencrypted. diff --git a/tests/render.yml b/tests/render.yml index ad9bbe5..f448e10 100755 --- a/tests/render.yml +++ b/tests/render.yml @@ -11,6 +11,9 @@ - name: Supply nonsecret fixture settings ansible.builtin.set_fact: www_membership_image: registry.example.org/www-membership:release-test + 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_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 diff --git a/tests/validate_manifests.py b/tests/validate_manifests.py index a946b54..cba3cad 100755 --- a/tests/validate_manifests.py +++ b/tests/validate_manifests.py @@ -51,6 +51,10 @@ assert plaintext_config["LDAP_URL"] == "ldap://openldap.openldap.svc.cluster.loc 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 tiers = json.loads(find(all_objects, "ConfigMap", "membership-tiers")["data"]["tiers.json"]) +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"]: @@ -76,6 +80,9 @@ apache = find(all_objects, "ConfigMap", "httpd")["data"] assert "http://membership:8000/members/" in apache["membership.conf"] assert "X-Forwarded-Proto" in apache["membership.conf"] assert "Require all denied" in apache["membership.conf"] +assert "LimitRequestBody 262144" in apache["membership.conf"] +assert '' in apache["membership.conf"] +assert "LimitRequestBody 2097152" in apache["membership.conf"] assert "env=!membership_sensitive" in apache["httpd.conf"] assert "LoadModule proxy_http_module" in apache["httpd.conf"] source = find(all_objects, "Deployment", "httpd")["spec"]["template"]["spec"]["initContainers"][0] @@ -83,6 +90,7 @@ assert "rm -rf" not in source["command"][-1] assert 'cp -a /tmp/www/htdocs/. /data/' in source["command"][-1] 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/proxy-body-size"] == "2m" assert ingress["spec"]["rules"][0]["http"]["paths"][0]["backend"]["service"]["name"] == "httpd" for obj in docs("external-membership-workloads"): if obj["kind"] == "Deployment":