From aa18f6b895b618fdf08793d16e204b7971775a67 Mon Sep 17 00:00:00 2001 From: Eric Meehan Date: Tue, 27 Oct 2026 15:56:06 -0400 Subject: [PATCH] Levels --- .env.example | 5 +- .vscode/tasks.json | 66 ++++++++++++++++++- README.md | 60 ++++++++++------- membership/config/settings.py | 5 ++ membership/deploy/dex-membership.example.yaml | 12 ++-- membership/deploy/groups.example.ldif | 8 +++ membership/members/billing.py | 6 +- .../migrations/0003_membership_levels.py | 61 +++++++++++++++++ membership/members/models.py | 1 + membership/members/tests/test_membership.py | 25 ++++--- membership/members/tests/test_postgres.py | 4 +- membership/members/tests/test_shkeeper.py | 4 ++ membership/members/views.py | 17 +++-- membership/templates/members.html | 6 +- membership/tiers.eom.json | 25 ++++--- 15 files changed, 243 insertions(+), 62 deletions(-) create mode 100755 membership/members/migrations/0003_membership_levels.py diff --git a/.env.example b/.env.example index 9a48571..0d31e52 100755 --- a/.env.example +++ b/.env.example @@ -17,7 +17,7 @@ POSTGRES_SSLMODE=prefer DEX_ISSUER=https://dex.eom.dev/ DEX_CLIENT_ID=www-membership DEX_CLIENT_SECRET= -DEX_CONNECTOR_ID=www-ldap +DEX_CONNECTOR_ID=ldap LDAP_URL=ldaps://ldap.eom.dev # Set to 1 ONLY with ldap:// on a trusted network to skip StartTLS. # Bind and registration passwords will be sent unencrypted. LDAPS stays verified. @@ -54,5 +54,6 @@ SHKEEPER_CA_CERT_FILE= MEMBERSHIP_TIERS_FILE=/app/tiers.eom.json # These are existing LIVE-mode prices; use separate test-mode prices in sandbox. # Set MEMBERSHIP_TIERS_JSON instead to override the file ({} disables paid tiers). +# Each configured paid tier needs a unique positive level (level 0 is free). # Example shape (names/prices/group DNs are your choice, not created by the app): -# MEMBERSHIP_TIERS_JSON={"supporter":{"label":"Supporter","price_id":"price_REPLACE","price_label":"$5 / month","group_dn":"cn=www-supporter,ou=groups,dc=eom,dc=dev","description":"Support independent projects and community services."}} +# MEMBERSHIP_TIERS_JSON={"supporter":{"level":1,"label":"Supporter","price_id":"price_REPLACE","price_label":"$5 / month","group_dn":"cn=www-supporter,ou=groups,dc=eom,dc=dev","description":"Support independent projects and community services."}} diff --git a/.vscode/tasks.json b/.vscode/tasks.json index dcdc05b..6985836 100755 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -18,6 +18,70 @@ "isBackground": false, "problemMatcher": [], "group": "test" + }, + { + "label": "Test membership offline", + "type": "shell", + "command": "C:/Users/ermeehan/AppData/Local/Microsoft/WindowsApps/python3.12.exe", + "args": [ + "${workspaceFolder}/membership/manage.py", + "test", + "members.tests.test_membership", + "members.tests.test_oidc", + "members.tests.test_postgres", + "members.tests.test_shkeeper", + "--settings=config.test_settings", + "--verbosity=1" + ], + "isBackground": false, + "problemMatcher": [], + "group": "test" + }, + { + "label": "Check membership migrations", + "type": "shell", + "command": "C:/Users/ermeehan/AppData/Local/Microsoft/WindowsApps/python3.12.exe", + "args": [ + "${workspaceFolder}/membership/manage.py", + "makemigrations", + "--check", + "--dry-run", + "--settings=config.test_settings" + ], + "isBackground": false, + "problemMatcher": [], + "group": "test" + }, + { + "label": "Retest membership after expiry assertion", + "type": "shell", + "command": "C:/Users/ermeehan/AppData/Local/Microsoft/WindowsApps/python3.12.exe", + "args": [ + "${workspaceFolder}/membership/manage.py", + "test", + "members.tests.test_membership", + "members.tests.test_oidc", + "members.tests.test_postgres", + "members.tests.test_shkeeper", + "--settings=config.test_settings", + "--verbosity=1" + ], + "isBackground": false, + "problemMatcher": [], + "group": "test" + }, + { + "label": "Check production tier configuration", + "type": "shell", + "command": "$env:MEMBERSHIP_TIERS_FILE='Z:\\Repositories\\www\\membership\\tiers.eom.json'; C:/Users/ermeehan/AppData/Local/Microsoft/WindowsApps/python3.12.exe", + "args": [ + "${workspaceFolder}/membership/manage.py", + "check", + "--settings=config.test_settings" + ], + "isBackground": false, + "problemMatcher": [], + "group": "test" } ] -} +} \ No newline at end of file diff --git a/README.md b/README.md index 50366b8..80cbf0c 100644 --- a/README.md +++ b/README.md @@ -8,15 +8,16 @@ Both payment methods feed the same LDAP group reconciliation and local expiry. ## Membership flow -1. Anyone can request a free account. A one-hour, single-use email link allows +1. Anyone can request a free level-0 account. A one-hour, single-use email link allows them to choose a password. The account is created as an `inetOrgPerson` in OpenLDAP; the password goes only through RFC 3062 Password Modify over certificate-verified LDAPS/StartTLS by default (see the explicit plaintext option below). This app never stores a password/hash. -2. Sign-in uses Dex Authorization Code + PKCE, state and nonce. Authlib verifies - the RS256 ID token, issuer, audience, signature and lifetime. A signed - `federated_claims` connector/user ID must match the configured LDAP connector - and LDAP `entryUUID`; accounts are **not** linked using email alone. +2. Sign-in uses the single Dex LDAP option with Authorization Code + PKCE, state + and nonce. Authlib verifies the RS256 ID token, issuer, audience, signature + and lifetime. The signed connector/UID must match the configured connector + and LDAP identity; account ownership is anchored to immutable `entryUUID`, + never email alone. 3. Authenticated users choose a server-allowlisted recurring Stripe Price. Stripe hosts Checkout and the billing portal. Return URLs never grant access. 4. Stripe webhooks are signature-verified and durably deduplicated. The worker @@ -45,13 +46,22 @@ The live Stripe dashboard was inspected on 2026-10-23. This integration reuses **Subscription**, product `prod_Sc9N5J0vNAdLCn`, without modifying its prices, metadata, Discourse integration, or existing subscriptions. -| Tier | Monthly USD | Existing live Price ID | Proposed dedicated LDAP group | +| Membership | Monthly USD | Existing live Price ID | Proposed dedicated LDAP group | | --- | ---: | --- | --- | -| Level 0 | $4 | `price_1Rgv4xC5KPXqTvVLfMd6z7wn` | `www-level-0` | -| Level 1 | $8 | `price_1Rgv5QC5KPXqTvVLv1PaHWb5` | `www-level-1` | -| Level 2 | $16 | `price_1Rgv5rC5KPXqTvVL8Ol8sMUf` | `www-level-2` | -| Level 3 | $32 | `price_1Rgv6FC5KPXqTvVL39mAxYjm` | `www-level-3` | -| Level 4 | $64 | `price_1Rgv6mC5KPXqTvVLkcmznWZa` | `www-level-4` | +| Level 0 · Community | Free | — | — | +| Level 1 · Supporter | $4 | `price_1Rgv4xC5KPXqTvVLfMd6z7wn` | `www-level-1` | +| Level 2 · Insider | $8 | `price_1Rgv5QC5KPXqTvVLv1PaHWb5` | `www-level-2` | +| Level 3 · Collaborator | $16 | `price_1Rgv5rC5KPXqTvVL8Ol8sMUf` | `www-level-3` | +| Level 4 · Sustainer | $32 | `price_1Rgv6FC5KPXqTvVL39mAxYjm` | `www-level-4` | +| Level 5 · Patron | $64 | `price_1Rgv6mC5KPXqTvVLkcmznWZa` | `www-level-5` | + +Every account has a non-null `membership_level`: new/free accounts start at 0, +and synchronization records the highest currently valid paid level. The former +paid levels 0–4 are shifted to 1–5; Stripe Price IDs remain unchanged. The +migration also updates stored subscription and prepaid-invoice tier keys and +backfills current account levels. Existing level-0 LDAP group grants are +revoked by the worker after the migration; retain that legacy group until the +first successful synchronization of existing accounts. The deployable mapping is in [membership/tiers.eom.json](membership/tiers.eom.json). The **group DNs are deployment proposals**, not verified existing groups: create @@ -97,17 +107,18 @@ of these five prices when inspected. and exposes bind passwords, registration passwords and directory data to the network. The default is `0` (StartTLS required for `ldap://`); TLS failures never trigger a plaintext fallback, and `ldaps://` always verifies certificates. -4. Add a dedicated Dex client/LDAP connector using +4. Add the website client to Dex and merge website account entries into its + existing LDAP connector using [membership/deploy/dex-membership.example.yaml](membership/deploy/dex-membership.example.yaml). The exact callback is `https://eom.dev/members/oidc/callback/` (trailing slash). Set the same client secret in Dex and `DEX_CLIENT_SECRET`, and set - `DEX_CONNECTOR_ID=www-ldap`. This connector uses `idAttr: entryUUID`, - `preferredUsernameAttr: uid`, `emailAttr: mail`; scopes include `federated:id`. - **Do not change the existing connector's idAttr blindly**: that would change - subjects in other apps. Use the additional connector instead. Dex must search - the account OU without requiring membership in a paid group. RS256 and all - discovery endpoints must be HTTPS on the issuer's origin. Existing lowercase - LDAP usernames matching the registration syntax can sign in without signup. + `DEX_CONNECTOR_ID=ldap`. Keep the connector ID and `idAttr: uid` unchanged to + preserve subjects in other apps. Add the website `inetOrgPerson` branch to the + LDAP filter; this allows those entries to authenticate to all Dex clients, so + review that scope. Dex uses signed `federated:id` claims, while the app resolves + UID to LDAP and checks immutable `entryUUID`. RS256 and all discovery endpoints + must be HTTPS on the issuer's origin. Existing lowercase LDAP usernames + matching the registration syntax can sign in without signup. 5. The supplied tier file contains live Price IDs. Set `STRIPE_LIVE_MODE=1` only with the matching live Stripe API key. For sandbox testing, override `MEMBERSHIP_TIERS_JSON` with **test-mode** recurring prices and set live mode @@ -193,8 +204,9 @@ To enable: or triggers an API request. The worker polls known local invoice UUIDs over authenticated HTTPS, so forged, repeated, missing or reordered callbacks cannot grant access or extend a term. -5. Redeploy the image and run the migration service, which applies - [membership/members/migrations/0002_crypto_invoices.py](membership/members/migrations/0002_crypto_invoices.py). +5. Redeploy the image and run the migration service, which applies the pending + membership schema/data migrations, including + [membership/members/migrations/0003_membership_levels.py](membership/members/migrations/0003_membership_levels.py). Keep the membership worker running; it independently refreshes Stripe and SHKeeper, and performs a local-only expiry sweep before remote requests. 6. Smoke-test a low-value invoice on your actual deployment before offering @@ -274,9 +286,9 @@ Payment lifecycle: current implementation follows paid invoices plus subscription state. If a refund/dispute should revoke access, cancel/suspend the subscription as part of your reviewed refund/dispute process. -- Free accounts are `inetOrgPerson`, not automatically POSIX/shell accounts and - not automatically members of any service group. Password resets remain your - existing LDAP/Dex account recovery process. +- Level-0 accounts are `inetOrgPerson`, not automatically POSIX/shell accounts + and not automatically members of any service group. Password resets remain + your existing LDAP/Dex account recovery process. ## Verification diff --git a/membership/config/settings.py b/membership/config/settings.py index 15a9d89..0945112 100755 --- a/membership/config/settings.py +++ b/membership/config/settings.py @@ -118,6 +118,7 @@ MEMBERSHIP_TIERS = json.loads(os.environ["MEMBERSHIP_TIERS_JSON"]) if "MEMBERSHI if not isinstance(MEMBERSHIP_TIERS, dict): raise ImproperlyConfigured("MEMBERSHIP_TIERS_JSON must be an object.") prices = set() +levels = set() for key, tier in MEMBERSHIP_TIERS.items(): if not re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,47}", key) or not isinstance(tier, dict): raise ImproperlyConfigured("Invalid membership tier.") @@ -127,6 +128,10 @@ for key, tier in MEMBERSHIP_TIERS.items(): if not tier["price_id"].startswith("price_") or tier["price_id"] in prices: raise ImproperlyConfigured("Each tier must have a unique Stripe Price ID.") prices.add(tier["price_id"]) + level = tier.get("level") + if not isinstance(level, int) or isinstance(level, bool) or level < 1 or level in levels: + raise ImproperlyConfigured(f"Tier {key} must have a unique positive integer level; level 0 is reserved for free accounts.") + levels.add(level) if "usd_amount" in tier: try: amount = Decimal(tier["usd_amount"]) diff --git a/membership/deploy/dex-membership.example.yaml b/membership/deploy/dex-membership.example.yaml index 2f1ddff..277ae54 100755 --- a/membership/deploy/dex-membership.example.yaml +++ b/membership/deploy/dex-membership.example.yaml @@ -1,5 +1,5 @@ # Merge with the EXISTING Dex configuration; do not replace its storage/issuer. -# A new connector avoids changing subjects for other applications using LDAP. +# Extend the existing ldap connector instead of adding a second login option. staticClients: - id: www-membership name: eom.dev Membership @@ -9,8 +9,8 @@ staticClients: connectors: - type: ldap - id: www-ldap - name: eom.dev accounts + id: ldap + name: eom.dev account config: host: ldap.eom.dev:636 insecureNoSSL: false @@ -19,10 +19,10 @@ connectors: bindDN: cn=dex-reader,ou=system,dc=eom,dc=dev bindPW: $DEX_LDAP_BIND_PASSWORD userSearch: - baseDN: ou=people,dc=eom,dc=dev - filter: "(objectClass=inetOrgPerson)" + baseDN: dc=eom,dc=dev + filter: "(|(&(objectClass=posixAccount)(memberOf=cn=Dex Users,ou=Dex,ou=Services,dc=eom,dc=dev))(objectClass=inetOrgPerson))" username: uid - idAttr: entryUUID + idAttr: uid emailAttr: mail nameAttr: cn preferredUsernameAttr: uid diff --git a/membership/deploy/groups.example.ldif b/membership/deploy/groups.example.ldif index 3ba4e38..0cc15ac 100755 --- a/membership/deploy/groups.example.ldif +++ b/membership/deploy/groups.example.ldif @@ -6,6 +6,8 @@ objectClass: organizationalRole cn: membership-sentinel description: Non-login placeholder for empty paid membership groups +# Legacy paid level-0 group. Keep until existing deployments have synchronized +# all accounts and removed their former Supporter grants. dn: cn=www-level-0,ou=groups,dc=eom,dc=dev objectClass: top objectClass: groupOfUniqueNames @@ -35,3 +37,9 @@ objectClass: top objectClass: groupOfUniqueNames cn: www-level-4 uniqueMember: cn=membership-sentinel,ou=system,dc=eom,dc=dev + +dn: cn=www-level-5,ou=groups,dc=eom,dc=dev +objectClass: top +objectClass: groupOfUniqueNames +cn: www-level-5 +uniqueMember: cn=membership-sentinel,ou=system,dc=eom,dc=dev diff --git a/membership/members/billing.py b/membership/members/billing.py index b113ce9..9c650dc 100755 --- a/membership/members/billing.py +++ b/membership/members/billing.py @@ -90,18 +90,22 @@ def refresh_billing(account): def apply_groups(account): now = timezone.now() desired = set() + membership_level = 0 for sub in Subscription.objects.filter(account=account, status__in=["active", "past_due"], paid_until__gt=now): tier = settings.MEMBERSHIP_TIERS.get(sub.tier) if tier: desired.add(tier["group_dn"]) + membership_level = max(membership_level, tier.get("level", 0)) for invoice in CryptoInvoice.objects.filter(account=account, status__in=["PAID", "OVERPAID"], access_start__lte=now, access_end__gt=now): tier = settings.MEMBERSHIP_TIERS.get(invoice.tier) if tier: desired.add(tier["group_dn"]) + membership_level = max(membership_level, tier.get("level", 0)) managed = set(ManagedGroup.objects.filter(account=account).values_list("dn", flat=True)) account.ldap_dn = reconcile_groups(account, desired, managed) if managed else account.ldap_dn account.last_ldap_sync = now - account.save(update_fields=["ldap_dn", "last_ldap_sync"]) + account.membership_level = membership_level + account.save(update_fields=["ldap_dn", "last_ldap_sync", "membership_level"]) return len(desired) diff --git a/membership/members/migrations/0003_membership_levels.py b/membership/members/migrations/0003_membership_levels.py new file mode 100755 index 0000000..5e47f89 --- /dev/null +++ b/membership/members/migrations/0003_membership_levels.py @@ -0,0 +1,61 @@ +from django.db import migrations, models +from django.utils import timezone + + +LEGACY_LEVELS = {f"level-{level}": f"level-{level + 1}" for level in range(5)} + + +def _tier_level(tier): + prefix = "level-" + suffix = tier[len(prefix):] if tier.startswith(prefix) else "" + return int(suffix) if suffix.isdigit() else 0 + + +def migrate_membership_levels(apps, schema_editor, reverse=False): + Account = apps.get_model("members", "Account") + Subscription = apps.get_model("members", "Subscription") + CryptoInvoice = apps.get_model("members", "CryptoInvoice") + using = schema_editor.connection.alias + levels = {old: new for old, new in LEGACY_LEVELS.items()} + if reverse: + levels = {new: old for old, new in levels.items()} + + for model in (Subscription, CryptoInvoice): + for old, new in reversed(list(levels.items())): + model.objects.using(using).filter(tier=old).update(tier=new) + + now = timezone.now() + account_levels = {} + active_subscriptions = Subscription.objects.using(using).filter( + status__in=("active", "past_due"), paid_until__gt=now + ).values_list("account_id", "tier") + for account_id, tier in active_subscriptions.iterator(): + account_levels[account_id] = max(account_levels.get(account_id, 0), _tier_level(tier)) + + active_invoices = CryptoInvoice.objects.using(using).filter( + status__in=("PAID", "OVERPAID"), access_start__lte=now, access_end__gt=now + ).values_list("account_id", "tier") + for account_id, tier in active_invoices.iterator(): + account_levels[account_id] = max(account_levels.get(account_id, 0), _tier_level(tier)) + + batch = [] + for account in Account.objects.using(using).all().iterator(chunk_size=500): + account.membership_level = account_levels.get(account.pk, 0) + batch.append(account) + if len(batch) == 500: + Account.objects.using(using).bulk_update(batch, ["membership_level"]) + batch.clear() + if batch: + Account.objects.using(using).bulk_update(batch, ["membership_level"]) + + +class Migration(migrations.Migration): + dependencies = [("members", "0002_crypto_invoices")] + operations = [ + migrations.AddField( + model_name="account", + name="membership_level", + field=models.PositiveSmallIntegerField(default=0), + ), + migrations.RunPython(migrate_membership_levels, migrate_membership_levels), + ] diff --git a/membership/members/models.py b/membership/members/models.py index 549de0c..f985137 100755 --- a/membership/members/models.py +++ b/membership/members/models.py @@ -10,6 +10,7 @@ class Account(models.Model): ldap_dn = models.CharField(max_length=1024) ldap_uuid = models.CharField(max_length=64, unique=True) oidc_sub = models.CharField(max_length=512, unique=True, null=True) + membership_level = models.PositiveSmallIntegerField(default=0) stripe_customer = models.CharField(max_length=128, unique=True, null=True) checkout_id = models.CharField(max_length=128, blank=True) checkout_key = models.UUIDField(default=uuid.uuid4) diff --git a/membership/members/tests/test_membership.py b/membership/members/tests/test_membership.py index 2ca7505..22b9f05 100755 --- a/membership/members/tests/test_membership.py +++ b/membership/members/tests/test_membership.py @@ -22,8 +22,8 @@ BASE = "ou=people,dc=example,dc=org" DN = "uid=alice," + BASE SENTINEL = "cn=sentinel,dc=example,dc=org" TIERS = { - "supporter": {"label": "Supporter", "price_id": "price_supporter", "price_label": "$5 / month", "group_dn": GROUP}, - "patron": {"label": "Patron", "price_id": "price_patron", "price_label": "$15 / month", "group_dn": OTHER_GROUP}, + "supporter": {"label": "Supporter", "level": 1, "price_id": "price_supporter", "price_label": "$5 / month", "group_dn": GROUP}, + "patron": {"label": "Patron", "level": 2, "price_id": "price_patron", "price_label": "$15 / month", "group_dn": OTHER_GROUP}, } @@ -66,6 +66,7 @@ class RegistrationTests(TestCase): self.assertContains(response, "Account created") provision.assert_called_once_with("alice", "alice@example.org", password) self.assertEqual(Account.objects.count(), 1) + self.assertEqual(Account.objects.get().membership_level, 0) self.assertFalse(Subscription.objects.exists()) self.assertFalse(Registration.objects.exists()) self.assertNotIn("account_id", self.client.session) @@ -131,18 +132,20 @@ class RegistrationTests(TestCase): @override_settings(MEMBERSHIP_TIERS=TIERS) class IdentityTests(TestCase): def claims(self, **changes): - return {"iss": settings.DEX_ISSUER, "sub": "dex-alice", "preferred_username": "alice", "email_verified": True, "email": "alice@example.org", "federated_claims": {"connector_id": settings.DEX_CONNECTOR_ID, "user_id": "uuid-alice"}, **changes} + return {"iss": settings.DEX_ISSUER, "sub": "dex-alice", "preferred_username": "alice", "email_verified": True, "email": "alice@example.org", "federated_claims": {"connector_id": settings.DEX_CONNECTOR_ID, "user_id": "alice"}, **changes} @patch("members.views.directory.identity_for_login") def test_existing_directory_account_links_without_password(self, identity): identity.return_value = {"uid": "alice", "email": "alice@example.org", "ldap_dn": DN, "ldap_uuid": "uuid-alice"} first = views.link_identity(self.claims()) - second = views.link_identity(self.claims()) + second = views.link_identity(self.claims(sub="ldap-alice")) self.assertEqual(first.pk, second.pk) - self.assertEqual(first.oidc_sub, "dex-alice") + first.refresh_from_db() + self.assertEqual(first.oidc_sub, "ldap-alice") self.assertFalse(Subscription.objects.exists()) - with self.assertRaises(ValueError): - views.link_identity(self.claims(sub="different-sub")) + views.link_identity(self.claims(sub="next-verified-subject")) + first.refresh_from_db() + self.assertEqual(first.oidc_sub, "next-verified-subject") @patch("members.views.directory.identity_for_login") def test_no_email_only_or_unverified_link(self, identity): @@ -158,7 +161,7 @@ class IdentityTests(TestCase): @patch("members.views.directory.identity_for_login") def test_other_connector_or_recreated_ldap_account_rejected(self, identity): identity.return_value = {"uid": "alice", "email": "alice@example.org", "ldap_dn": DN, "ldap_uuid": "uuid-alice"} - for federated in ({"connector_id": "github", "user_id": "uuid-alice"}, {"connector_id": settings.DEX_CONNECTOR_ID, "user_id": "different-uuid"}, {}): + for federated in ({"connector_id": "github", "user_id": "alice"}, {"connector_id": settings.DEX_CONNECTOR_ID, "user_id": "different-uid"}, {}): with self.assertRaises(ValueError): views.link_identity(self.claims(federated_claims=federated)) self.assertFalse(Account.objects.exists()) @@ -291,6 +294,8 @@ class ReconciliationTests(TestCase): self.assertEqual(int(sub.paid_until.timestamp()), self.end) self.assertEqual(sub.tier, "supporter") self.assertEqual(ldap.call_args.args[1], {GROUP}) + self.member.refresh_from_db() + self.assertEqual(self.member.membership_level, 1) self.assertEqual(ManagedGroup.objects.count(), 2) @patch("members.billing.reconcile_groups", return_value=DN) @@ -345,6 +350,8 @@ class ReconciliationTests(TestCase): factory.return_value = self.api() billing.sync_account(self.member.pk) self.assertEqual(ldap.call_args.args[1], set()) + self.member.refresh_from_db() + self.assertEqual(self.member.membership_level, 0) @patch("members.billing.reconcile_groups", return_value=DN) @patch("members.billing.client", side_effect=OSError("offline")) @@ -426,6 +433,8 @@ class ReconciliationTests(TestCase): factory.return_value = api billing.sync_account(self.member.pk) self.assertEqual(ldap.call_args.args[1], {GROUP, OTHER_GROUP}) + self.member.refresh_from_db() + self.assertEqual(self.member.membership_level, 2) @override_settings(LDAP_SEARCH_BASE_DN=BASE, LDAP_GROUP_PLACEHOLDER_DN=SENTINEL) diff --git a/membership/members/tests/test_postgres.py b/membership/members/tests/test_postgres.py index 8a1fce9..8d1ad95 100755 --- a/membership/members/tests/test_postgres.py +++ b/membership/members/tests/test_postgres.py @@ -46,7 +46,7 @@ class PostgreSQLLockTests(TransactionTestCase): future.result(timeout=10) self.assertTrue(acquired.is_set()) - @override_settings(MEMBERSHIP_TIERS={"test": {"group_dn": "cn=paid,dc=example,dc=org"}}) + @override_settings(MEMBERSHIP_TIERS={"test": {"level": 1, "group_dn": "cn=paid,dc=example,dc=org"}}) def test_intent_commits_before_external_grant_failure(self): with patch("members.billing.reconcile_groups", side_effect=OSError("directory unavailable")): with self.assertRaises(OSError): @@ -55,7 +55,7 @@ class PostgreSQLLockTests(TransactionTestCase): # not the previously committed record of the managed group. self.assertEqual(ManagedGroup.objects.get(account=self.account).dn, "cn=paid,dc=example,dc=org") - @override_settings(SHKEEPER_ENABLED=True, MEMBERSHIP_TIERS={"test": {"group_dn": "cn=paid,dc=example,dc=org"}}) + @override_settings(SHKEEPER_ENABLED=True, MEMBERSHIP_TIERS={"test": {"level": 1, "group_dn": "cn=paid,dc=example,dc=org"}}) def test_concurrent_crypto_verification_credits_one_month(self): invoice = CryptoInvoice.objects.create(account=self.account, tier="test", usd_amount="5.00", crypto="BTC", provider_id="61", wallet="test-wallet", crypto_amount="0.0001", status="UNPAID") data = {"status": "success", "invoices": [{"external_id": f"www-membership-{invoice.pk}", "fiat": "USD", "amount_fiat": "5.00", "balance_fiat": "5.00", "status": "PAID", "txs": [{"addr": "test-wallet", "crypto": "BTC", "status": "CONFIRMED", "txid": "tx-confirmed", "amount": "0.0001"}]}]} diff --git a/membership/members/tests/test_shkeeper.py b/membership/members/tests/test_shkeeper.py index a5ed411..f68f65a 100755 --- a/membership/members/tests/test_shkeeper.py +++ b/membership/members/tests/test_shkeeper.py @@ -173,12 +173,16 @@ class SHKeeperTests(TestCase): request.return_value = self.remote(invoice) self.assertTrue(billing.sync_account(self.member.pk)) self.assertEqual(ldap.call_args.args[1], {GROUP}) + self.member.refresh_from_db() + self.assertEqual(self.member.membership_level, 1) request.side_effect = OSError("SHKeeper offline") self.assertFalse(billing.sync_account(self.member.pk)) self.assertEqual(ldap.call_args.args[1], {GROUP}) CryptoInvoice.objects.update(access_end=timezone.now() - timedelta(seconds=1)) billing.sync_account(self.member.pk, refresh_stripe=False) self.assertEqual(ldap.call_args.args[1], set()) + self.member.refresh_from_db() + self.assertEqual(self.member.membership_level, 0) @patch("members.billing.reconcile_groups", return_value=DN) @patch("members.shkeeper.request") diff --git a/membership/members/views.py b/membership/members/views.py index d93506b..2d56bf4 100755 --- a/membership/members/views.py +++ b/membership/members/views.py @@ -168,16 +168,23 @@ def link_identity(claims): raise ValueError("A verified Dex identity is required.") identity = directory.identity_for_login(claims.get("preferred_username")) federated = claims.get("federated_claims") or {} - if federated.get("connector_id") != settings.DEX_CONNECTOR_ID or federated.get("user_id") != identity["ldap_uuid"]: - raise ValueError("Dex must authenticate the configured LDAP connector with entryUUID as idAttr.") + if federated.get("connector_id") != settings.DEX_CONNECTOR_ID or federated.get("user_id") != identity["uid"]: + raise ValueError("Dex must authenticate the configured LDAP connector with uid as idAttr.") if identity["email"].casefold() != str(claims.get("email", "")).casefold(): raise ValueError("Dex and LDAP email disagree.") - # Link using the signed LDAP uid plus immutable entryUUID, NEVER by email. + # Resolve by immutable LDAP entryUUID, never by email. Dex's federated user + # ID remains uid so the unified connector preserves existing OIDC subjects. with transaction.atomic(): + uid_owner = Account.objects.filter(uid=identity["uid"]).first() + if uid_owner and uid_owner.ldap_uuid != identity["ldap_uuid"]: + raise ValueError("Username is already linked to another directory entry.") account, _ = Account.objects.get_or_create(ldap_uuid=identity["ldap_uuid"], defaults=identity) account = Account.objects.select_for_update().get(pk=account.pk) - if account.oidc_sub and account.oidc_sub != claims["sub"]: - raise ValueError("Identity is already linked to another Dex subject.") + subject_owner = Account.objects.filter(oidc_sub=claims["sub"]).exclude(pk=account.pk).exists() + if subject_owner: + raise ValueError("Dex subject is already linked to another directory entry.") + # A previous login may have used the retired www-ldap connector, whose + # subjects used entryUUID. Rebind after validating the same LDAP entry. account.oidc_sub = claims["sub"] account.email = identity["email"] account.ldap_dn = identity["ldap_dn"] diff --git a/membership/templates/members.html b/membership/templates/members.html index de2a9f5..f1bbe60 100755 --- a/membership/templates/members.html +++ b/membership/templates/members.html @@ -46,7 +46,7 @@

A private place to connect, a closer look at what is being made, and opportunities to help shape it. Start with a free eom.dev account, then choose an optional membership to support the independent work.

{% if account %}
-

Your eom.dev account

{{ account.uid }}

{{ account.email }}

+

Your eom.dev account · Membership level {{ account.membership_level }}

{{ account.uid }}

{{ account.email }}

{% if account.stripe_customer %}
{% csrf_token %}
{% endif %}
{% csrf_token %}
@@ -71,14 +71,14 @@

Your support, your choice

Find your membership

Public projects and ordinary community participation stay free. Membership adds a closer connection to the work, not a barrier to joining in.

-

The starting point

Community

Free / always

+

Level 0 · The starting point

Community

Free / always

One account for eom.dev, its community, and its services.

  • No payment required
  • One account for compatible community services
  • Upgrade whenever you choose
{% if not account %}Join the community{% else %}

Your free account is ready.

{% endif %}
{% for key, tier in tiers.items %}
-

Independent work, supported together

{{ tier.label }}

{{ tier.price_label }}

+

Level {{ tier.level }} · Independent work, supported together

{{ tier.label }}

{{ tier.price_label }}

{{ tier.description|default:"Help keep the projects and community running." }}

    {% for benefit in tier.benefits %}
  • {{ benefit }}
  • {% empty %}
  • Support independent projects and the community
  • {% endfor %}
{% if account %}
{% csrf_token %}
diff --git a/membership/tiers.eom.json b/membership/tiers.eom.json index 6324043..b72f7c2 100755 --- a/membership/tiers.eom.json +++ b/membership/tiers.eom.json @@ -1,10 +1,11 @@ { - "level-0": { + "level-1": { + "level": 1, "label": "Supporter", "price_id": "price_1Rgv4xC5KPXqTvVLfMd6z7wn", "price_label": "$4 USD / month", "usd_amount": "4.00", - "group_dn": "cn=www-level-0,ou=groups,dc=eom,dc=dev", + "group_dn": "cn=www-level-1,ou=groups,dc=eom,dc=dev", "description": "A private place to connect and a closer look at what is being made.", "benefits": [ "Access to the shared members' Matrix space", @@ -12,12 +13,13 @@ "Optional recognition on the supporters page" ] }, - "level-1": { + "level-2": { + "level": 2, "label": "Insider", "price_id": "price_1Rgv5QC5KPXqTvVLv1PaHWb5", "price_label": "$8 USD / month", "usd_amount": "8.00", - "group_dn": "cn=www-level-1,ou=groups,dc=eom,dc=dev", + "group_dn": "cn=www-level-2,ou=groups,dc=eom,dc=dev", "description": "See new experiments take shape, and try things before their public release.", "benefits": [ "Everything in Supporter", @@ -25,12 +27,13 @@ "Invitations to member playtests and listening sessions" ] }, - "level-2": { + "level-3": { + "level": 3, "label": "Collaborator", "price_id": "price_1Rgv5rC5KPXqTvVL8Ol8sMUf", "price_label": "$16 USD / month", "usd_amount": "16.00", - "group_dn": "cn=www-level-2,ou=groups,dc=eom,dc=dev", + "group_dn": "cn=www-level-3,ou=groups,dc=eom,dc=dev", "description": "Share your perspective and help shape the next community experiment.", "benefits": [ "Everything in Insider", @@ -38,12 +41,13 @@ "Invitations to occasional small-group feedback sessions" ] }, - "level-3": { + "level-4": { + "level": 4, "label": "Sustainer", "price_id": "price_1Rgv6FC5KPXqTvVL39mAxYjm", "price_label": "$32 USD / month", "usd_amount": "32.00", - "group_dn": "cn=www-level-3,ou=groups,dc=eom,dc=dev", + "group_dn": "cn=www-level-4,ou=groups,dc=eom,dc=dev", "description": "The same community access, with a larger contribution to keeping it independent.", "benefits": [ "Everything in Collaborator, with the same access", @@ -51,12 +55,13 @@ "Choose this level if you would like to give more" ] }, - "level-4": { + "level-5": { + "level": 5, "label": "Patron", "price_id": "price_1Rgv6mC5KPXqTvVLkcmznWZa", "price_label": "$64 USD / month", "usd_amount": "64.00", - "group_dn": "cn=www-level-4,ou=groups,dc=eom,dc=dev", + "group_dn": "cn=www-level-5,ou=groups,dc=eom,dc=dev", "description": "Help underwrite the public work and the community around it.", "benefits": [ "Everything in Collaborator, with the same access",